added optimistic updates

This commit is contained in:
Myzel394 2022-11-15 14:25:53 +01:00
parent dea48fdea2
commit 91e7b43a5b
8 changed files with 228 additions and 70 deletions

View File

@ -20,7 +20,6 @@ export interface AliasDetailsProps {
export default function AliasDetails({alias: aliasValue}: AliasDetailsProps): ReactElement {
const {t} = useTranslation()
const {enqueueSnackbar} = useSnackbar()
const params = useParams()
const {encryptionStatus} = useContext(AuthContext)
const address = atob(params.addressInBase64 as string)

View File

@ -11,7 +11,7 @@ import deepEqual from "deep-equal"
import format from "date-fns/format"
import update from "immutability-helper"
import {useMutation} from "@tanstack/react-query"
import {QueryKey, useMutation} from "@tanstack/react-query"
import {
Grid,
IconButton,
@ -31,6 +31,7 @@ import {FaviconImage, SimpleOverlayInformation} from "~/components"
import {Alias, AliasNote, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis"
import {useErrorSuccessSnacks} from "~/hooks"
import {queryClient} from "~/constants/react-query"
import AddWebsiteField from "~/route-widgets/AliasDetailRoute/AddWebsiteField"
import AuthContext from "~/AuthContext/AuthContext"
import FormikAutoLockNavigation from "~/LockNavigationContext/FormikAutoLockNavigation"
@ -40,7 +41,7 @@ export interface AliasNotesFormProps {
id: string
notes: AliasNote
onChanged: (alias: DecryptedAlias) => void
queryKey: QueryKey
}
interface Form {
@ -50,7 +51,7 @@ interface Form {
detail?: string
}
export default function AliasNotesForm({id, notes, onChanged}: AliasNotesFormProps): ReactElement {
export default function AliasNotesForm({id, notes, queryKey}: AliasNotesFormProps): ReactElement {
const {t} = useTranslation()
const {showError, showSuccess} = useErrorSuccessSnacks()
const {_encryptUsingMasterPassword, _decryptUsingMasterPassword} = useContext(AuthContext)
@ -72,7 +73,7 @@ export default function AliasNotesForm({id, notes, onChanged}: AliasNotesFormPro
const {mutateAsync} = useMutation<Alias, AxiosError, UpdateAliasData>(
values => updateAlias(id, values),
{
onSuccess: newAlias => {
onSuccess: async newAlias => {
;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes(
newAlias.encryptedNotes,
_decryptUsingMasterPassword,
@ -80,7 +81,9 @@ export default function AliasNotesForm({id, notes, onChanged}: AliasNotesFormPro
showSuccess(t("relations.alias.mutations.success.notesUpdated"))
onChanged(newAlias as any as DecryptedAlias)
await queryClient.cancelQueries(queryKey)
queryClient.setQueryData<DecryptedAlias | Alias>(queryKey, newAlias)
},
onError: showError,
},

View File

@ -10,7 +10,7 @@ import {useTranslation} from "react-i18next"
import {LoadingButton} from "@mui/lab"
import {Box, Collapse, Grid, Typography} from "@mui/material"
import {mdiTextBoxMultiple} from "@mdi/js/commonjs/mdi"
import {useMutation} from "@tanstack/react-query"
import {QueryKey, useMutation} from "@tanstack/react-query"
import Icon from "@mdi/react"
import {Alias, DecryptedAlias, ImageProxyFormatType, ProxyUserAgentType} from "~/server-types"
@ -21,6 +21,7 @@ import {
PROXY_USER_AGENT_TYPE_NAME_MAP,
} from "~/constants/enum-mappings"
import {useErrorSuccessSnacks} from "~/hooks"
import {queryClient} from "~/constants/react-query"
import AuthContext from "~/AuthContext/AuthContext"
import FormikAutoLockNavigation from "~/LockNavigationContext/FormikAutoLockNavigation"
import SelectField from "~/route-widgets/SettingsRoute/SelectField"
@ -29,7 +30,7 @@ import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
export interface AliasPreferencesFormProps {
alias: Alias | DecryptedAlias
onChanged: (newAlias: Alias | DecryptedAlias) => void
queryKey: QueryKey
}
interface Form {
@ -44,7 +45,7 @@ interface Form {
export default function AliasPreferencesForm({
alias,
onChanged,
queryKey,
}: AliasPreferencesFormProps): ReactElement {
const {t} = useTranslation()
const {showSuccess, showError} = useErrorSuccessSnacks()
@ -77,14 +78,16 @@ export default function AliasPreferencesForm({
const {mutateAsync} = useMutation<Alias, AxiosError, UpdateAliasData>(
data => updateAlias(alias.id, data),
{
onSuccess: alias => {
onSuccess: async newAlias => {
showSuccess(t("relations.alias.mutations.success.aliasUpdated"))
;(alias as any as DecryptedAlias).notes = decryptAliasNotes(
alias.encryptedNotes,
;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes(
newAlias.encryptedNotes,
_decryptUsingMasterPassword,
)
onChanged(alias)
await queryClient.cancelQueries(queryKey)
queryClient.setQueryData<DecryptedAlias | Alias>(queryKey, newAlias)
},
onError: showError,
},

View File

@ -1,37 +1,54 @@
import {ReactElement, useContext} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import update from "immutability-helper"
import {Switch} from "@mui/material"
import {useMutation} from "@tanstack/react-query"
import {QueryKey, useMutation} from "@tanstack/react-query"
import {Alias, DecryptedAlias} from "~/server-types"
import {UpdateAliasData, updateAlias} from "~/apis"
import {useErrorSuccessSnacks, useUIState} from "~/hooks"
import {queryClient} from "~/constants/react-query"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
export interface ChangeAliasActivationStatusSwitchProps {
id: string
isActive: boolean
onChanged: (alias: Alias | DecryptedAlias) => void
queryKey: QueryKey
}
export default function ChangeAliasActivationStatusSwitch({
id,
isActive,
onChanged,
queryKey,
}: ChangeAliasActivationStatusSwitchProps): ReactElement {
const {t} = useTranslation()
const {showError, showSuccess} = useErrorSuccessSnacks()
const {_decryptUsingMasterPassword, encryptionStatus} = useContext(AuthContext)
const [isActiveUIState, setIsActiveUIState] = useUIState<boolean>(isActive)
const {mutateAsync, isLoading} = useMutation<
Alias,
AxiosError,
UpdateAliasData,
{previousAlias: DecryptedAlias | Alias | undefined}
>(values => updateAlias(id, values), {
onMutate: async values => {
await queryClient.cancelQueries(queryKey)
const {mutateAsync, isLoading} = useMutation<Alias, AxiosError, UpdateAliasData>(
values => updateAlias(id, values),
{
const previousAlias = queryClient.getQueryData<DecryptedAlias | Alias>(queryKey)
queryClient.setQueryData<DecryptedAlias | Alias>(queryKey, old =>
update(old, {
isActive: {
$set: values.isActive!,
},
}),
)
return {previousAlias}
},
onSuccess: newAlias => {
if (encryptionStatus === EncryptionStatus.Available) {
;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes(
@ -40,26 +57,32 @@ export default function ChangeAliasActivationStatusSwitch({
)
}
onChanged(newAlias)
queryClient.setQueryData<DecryptedAlias | Alias>(queryKey, newAlias)
},
onError: showError,
onError: (error, values, context) => {
showError(error)
if (context?.previousAlias) {
queryClient.setQueryData<DecryptedAlias | Alias>(queryKey, context.previousAlias)
}
},
)
})
return (
<Switch
checked={isActiveUIState}
disabled={isActiveUIState === null || isLoading}
checked={isActive}
onChange={async () => {
setIsActiveUIState(!isActiveUIState)
if (isLoading) {
return
}
try {
await mutateAsync({
isActive: !isActiveUIState,
isActive: !isActive,
})
showSuccess(
isActiveUIState
isActive
? t("relations.alias.mutations.success.aliasChangedToDisabled")
: t("relations.alias.mutations.success.aliasChangedToEnabled"),
)

View File

@ -19,17 +19,14 @@ import {
import {useMutation} from "@tanstack/react-query"
import {CreateAliasData, createAlias} from "~/apis"
import {Alias, AliasType} from "~/server-types"
import {Alias, AliasList, AliasType, PaginationResult} from "~/server-types"
import {DEFAULT_ALIAS_NOTE} from "~/constants/values"
import {useErrorSuccessSnacks} from "~/hooks"
import {queryClient} from "~/constants/react-query"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import CustomAliasDialog from "~/route-widgets/AliasesRoute/CustomAliasDialog"
export interface CreateAliasButtonProps {
onCreated: (alias: Alias) => void
}
export default function CreateAliasButton({onCreated}: CreateAliasButtonProps): ReactElement {
export function CreateAliasButton(): ReactElement {
const {t} = useTranslation()
const {showSuccess, showError} = useErrorSuccessSnacks()
const {_encryptUsingMasterPassword, encryptionStatus} = useContext(AuthContext)
@ -53,11 +50,28 @@ export default function CreateAliasButton({onCreated}: CreateAliasButtonProps):
return createAlias(values)
},
{
onSuccess: alias => {
onCreated(alias)
showSuccess(t("relations.alias.mutations.success.aliasCreation"))
},
onError: showError,
onSuccess: async alias => {
showSuccess(t("relations.alias.mutations.success.aliasCreation"))
await queryClient.cancelQueries({
queryKey: ["get_aliases", ""],
})
queryClient.setQueryData<PaginationResult<AliasList>>(["get_aliases", ""], old => {
if (old) {
return update(old, {
items: {
$unshift: [alias],
},
})
}
return old
})
return alias
},
},
)

View File

@ -71,7 +71,7 @@ export default function SelectField({
formik.setFieldValue(name, value)
}}
disabled={formik.isSubmitting}
disabled={formik.sSubmitting}
error={Boolean(formik.touched[name] && formik.errors[name])}
renderValue={value =>
value === "null" ? (

View File

@ -4,24 +4,33 @@ import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {useQuery} from "@tanstack/react-query"
import {Grid} from "@mui/material"
import {getAlias} from "~/apis"
import {Alias, DecryptedAlias} from "~/server-types"
import {QueryResult, SimplePage} from "~/components"
import {
AliasTypeIndicator,
DecryptionPasswordMissingAlert,
QueryResult,
SimplePage,
SimplePageBuilder,
} from "~/components"
import AliasAddress from "~/route-widgets/AliasDetailRoute/AliasAddress"
import AliasDetails from "~/route-widgets/AliasDetailRoute/AliasDetails"
import AliasNotesForm from "~/route-widgets/AliasDetailRoute/AliasNotesForm"
import AliasPreferencesForm from "~/route-widgets/AliasDetailRoute/AliasPreferencesForm"
import AuthContext, {EncryptionStatus} from "~/AuthContext/AuthContext"
import ChangeAliasActivationStatusSwitch from "~/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch"
import decryptAliasNotes from "~/apis/helpers/decrypt-alias-notes"
export default function AliasDetailRoute(): ReactElement {
const {t} = useTranslation()
const params = useParams()
const address = atob(params.addressInBase64 as string)
const {_decryptUsingMasterPassword, encryptionStatus} =
useContext(AuthContext)
const queryKey = ["get_alias", address]
const {_decryptUsingMasterPassword, encryptionStatus} = useContext(AuthContext)
const query = useQuery<Alias | DecryptedAlias, AxiosError>(
["get_alias", params.addressInBase64],
async () => {
const query = useQuery<Alias | DecryptedAlias, AxiosError>(queryKey, async () => {
const alias = await getAlias(address)
if (encryptionStatus === EncryptionStatus.Available) {
@ -32,13 +41,55 @@ export default function AliasDetailRoute(): ReactElement {
}
return alias
},
)
})
return (
<SimplePage title={t("routes.AliasDetailRoute.title")}>
<QueryResult<Alias | DecryptedAlias> query={query}>
{alias => <AliasDetails alias={alias} />}
{alias => (
<SimplePageBuilder.MultipleSections>
{[
<Grid
key="basic"
container
spacing={1}
direction="row"
alignItems="center"
>
<Grid item>
<AliasTypeIndicator type={alias.type} />
</Grid>
<Grid item>
<AliasAddress address={address} />
</Grid>
<Grid item>
<ChangeAliasActivationStatusSwitch
id={alias.id}
isActive={alias.isActive}
queryKey={queryKey}
/>
</Grid>
</Grid>,
<div key="notes">
{encryptionStatus === EncryptionStatus.Available ? (
<AliasNotesForm
id={alias.id}
notes={(alias as DecryptedAlias).notes}
queryKey={queryKey}
/>
) : (
<DecryptionPasswordMissingAlert />
)}
</div>,
<SimplePageBuilder.Section
label={t("routes.AliasDetailRoute.sections.settings.title")}
key="settings"
>
<AliasPreferencesForm alias={alias} queryKey={queryKey} />
</SimplePageBuilder.Section>,
]}
</SimplePageBuilder.MultipleSections>
)}
</QueryResult>
</SimplePage>
)

View File

@ -2,13 +2,17 @@ import {ReactElement, useState, useTransition} from "react"
import {AxiosError} from "axios"
import {MdSearch} from "react-icons/md"
import {useTranslation} from "react-i18next"
import {useCopyToClipboard, useKeyPress, useUpdateEffect} from "react-use"
import {useQuery} from "@tanstack/react-query"
import {InputAdornment, TextField} from "@mui/material"
import {Alert, Grid, InputAdornment, List, Snackbar, TextField} from "@mui/material"
import {AliasList, PaginationResult} from "~/server-types"
import {QueryResult, SimplePage} from "~/components"
import AliasesDetails from "~/route-widgets/AliasesRoute/AliasesDetails"
import {ErrorSnack, NoSearchResults, QueryResult, SimplePage, SuccessSnack} from "~/components"
import {useIsAnyInputFocused} from "~/hooks"
import {CreateAliasButton} from "~/route-widgets/AliasesRoute/CreateAliasButton"
import AliasesListItem from "~/route-widgets/AliasesRoute/AliasesListItem"
import EmptyStateScreen from "~/route-widgets/AliasesRoute/EmptyStateScreen"
import getAliases from "~/apis/get-aliases"
export default function AliasesRoute(): ReactElement {
@ -19,6 +23,12 @@ export default function AliasesRoute(): ReactElement {
const [, startTransition] = useTransition()
const [showSearch, setShowSearch] = useState<boolean>(false)
const [{value, error}, copyToClipboard] = useCopyToClipboard()
const [isPressingControl] = useKeyPress("Shift")
const isAnyInputFocused = useIsAnyInputFocused()
const [lockDisabledCopyMode, setLockDisabledCopyMode] = useState<boolean>(false)
const isInCopyAddressMode = !isAnyInputFocused && !lockDisabledCopyMode && isPressingControl
const query = useQuery<PaginationResult<AliasList>, AxiosError>(
["get_aliases", queryValue],
() =>
@ -34,6 +44,12 @@ export default function AliasesRoute(): ReactElement {
},
)
useUpdateEffect(() => {
if (!isPressingControl) {
setLockDisabledCopyMode(false)
}
}, [isPressingControl])
return (
<SimplePage
title={t("routes.AliasesRoute.title")}
@ -63,8 +79,57 @@ export default function AliasesRoute(): ReactElement {
}
>
<QueryResult<PaginationResult<AliasList>, AxiosError> query={query}>
{result => (
<AliasesDetails aliases={result.items} isSearching={searchValue !== ""} />
{({items: aliases}) => (
<>
<Grid container spacing={4} direction="column">
<Grid item>
{(() => {
if (aliases.length === 0) {
if (searchValue === "") {
return <EmptyStateScreen />
} else {
return <NoSearchResults />
}
}
return (
<List>
{aliases.map(alias => (
<AliasesListItem
alias={alias}
key={alias.id}
onCopy={
isInCopyAddressMode
? alias => {
copyToClipboard(alias)
setLockDisabledCopyMode(true)
}
: undefined
}
/>
))}
</List>
)
})()}
</Grid>
<Grid item>
<CreateAliasButton />
</Grid>
</Grid>
<SuccessSnack
key={value}
message={
value &&
t("relations.alias.mutations.success.addressCopiedToClipboard")
}
/>
<ErrorSnack message={error && t("general.copyError")} />
<Snackbar open={isInCopyAddressMode} autoHideDuration={null}>
<Alert variant="standard" severity="info">
{t("routes.AliasesRoute.isInCopyMode")}
</Alert>
</Snackbar>
</>
)}
</QueryResult>
</SimplePage>