From 91e7b43a5b1e1182701a97542de8434fc395f8c2 Mon Sep 17 00:00:00 2001 From: Myzel394 <50424412+Myzel394@users.noreply.github.com> Date: Tue, 15 Nov 2022 14:25:53 +0100 Subject: [PATCH] added optimistic updates --- .../AliasDetailRoute/AliasDetails.tsx | 1 - .../AliasDetailRoute/AliasNotesForm.tsx | 13 +-- .../AliasDetailRoute/AliasPreferencesForm.tsx | 17 ++-- .../ChangeAliasActivationStatusSwitch.tsx | 71 ++++++++++------ .../AliasesRoute/CreateAliasButton.tsx | 34 +++++--- .../SettingsRoute/SelectField.tsx | 2 +- src/routes/AliasDetailRoute.tsx | 85 +++++++++++++++---- src/routes/AliasesRoute.tsx | 75 ++++++++++++++-- 8 files changed, 228 insertions(+), 70 deletions(-) diff --git a/src/route-widgets/AliasDetailRoute/AliasDetails.tsx b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx index 11e33d5..9124dbd 100644 --- a/src/route-widgets/AliasDetailRoute/AliasDetails.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasDetails.tsx @@ -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) diff --git a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx index a1e6ff8..13ad55e 100644 --- a/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasNotesForm.tsx @@ -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( 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(queryKey, newAlias) }, onError: showError, }, diff --git a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx index a1606d0..8b552a8 100644 --- a/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx +++ b/src/route-widgets/AliasDetailRoute/AliasPreferencesForm.tsx @@ -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( 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(queryKey, newAlias) }, onError: showError, }, diff --git a/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx b/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx index 60bf631..b1f5143 100644 --- a/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx +++ b/src/route-widgets/AliasDetailRoute/ChangeAliasActivationStatusSwitch.tsx @@ -1,65 +1,88 @@ 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(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( - values => updateAlias(id, values), - { - onSuccess: newAlias => { - if (encryptionStatus === EncryptionStatus.Available) { - ;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes( - newAlias.encryptedNotes, - _decryptUsingMasterPassword, - ) - } + const previousAlias = queryClient.getQueryData(queryKey) - onChanged(newAlias) - }, - onError: showError, + queryClient.setQueryData(queryKey, old => + update(old, { + isActive: { + $set: values.isActive!, + }, + }), + ) + + return {previousAlias} }, - ) + onSuccess: newAlias => { + if (encryptionStatus === EncryptionStatus.Available) { + ;(newAlias as any as DecryptedAlias).notes = decryptAliasNotes( + newAlias.encryptedNotes, + _decryptUsingMasterPassword, + ) + } + + queryClient.setQueryData(queryKey, newAlias) + }, + onError: (error, values, context) => { + showError(error) + + if (context?.previousAlias) { + queryClient.setQueryData(queryKey, context.previousAlias) + } + }, + }) return ( { - 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"), ) diff --git a/src/route-widgets/AliasesRoute/CreateAliasButton.tsx b/src/route-widgets/AliasesRoute/CreateAliasButton.tsx index 512ed86..d859067 100644 --- a/src/route-widgets/AliasesRoute/CreateAliasButton.tsx +++ b/src/route-widgets/AliasesRoute/CreateAliasButton.tsx @@ -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>(["get_aliases", ""], old => { + if (old) { + return update(old, { + items: { + $unshift: [alias], + }, + }) + } + + return old + }) + + return alias + }, }, ) diff --git a/src/route-widgets/SettingsRoute/SelectField.tsx b/src/route-widgets/SettingsRoute/SelectField.tsx index 44d5336..a12573b 100644 --- a/src/route-widgets/SettingsRoute/SelectField.tsx +++ b/src/route-widgets/SettingsRoute/SelectField.tsx @@ -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" ? ( diff --git a/src/routes/AliasDetailRoute.tsx b/src/routes/AliasDetailRoute.tsx index a2c2b99..8318585 100644 --- a/src/routes/AliasDetailRoute.tsx +++ b/src/routes/AliasDetailRoute.tsx @@ -4,41 +4,92 @@ 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( - ["get_alias", params.addressInBase64], - async () => { - const alias = await getAlias(address) + const query = useQuery(queryKey, async () => { + const alias = await getAlias(address) - if (encryptionStatus === EncryptionStatus.Available) { - ;(alias as any as DecryptedAlias).notes = decryptAliasNotes( - alias.encryptedNotes, - _decryptUsingMasterPassword, - ) - } + if (encryptionStatus === EncryptionStatus.Available) { + ;(alias as any as DecryptedAlias).notes = decryptAliasNotes( + alias.encryptedNotes, + _decryptUsingMasterPassword, + ) + } - return alias - }, - ) + return alias + }) return ( query={query}> - {alias => } + {alias => ( + + {[ + + + + + + + + + + + , +
+ {encryptionStatus === EncryptionStatus.Available ? ( + + ) : ( + + )} +
, + + + , + ]} +
+ )}
) diff --git a/src/routes/AliasesRoute.tsx b/src/routes/AliasesRoute.tsx index 603302c..985f9ee 100644 --- a/src/routes/AliasesRoute.tsx +++ b/src/routes/AliasesRoute.tsx @@ -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(false) + const [{value, error}, copyToClipboard] = useCopyToClipboard() + const [isPressingControl] = useKeyPress("Shift") + const isAnyInputFocused = useIsAnyInputFocused() + const [lockDisabledCopyMode, setLockDisabledCopyMode] = useState(false) + const isInCopyAddressMode = !isAnyInputFocused && !lockDisabledCopyMode && isPressingControl + const query = useQuery, AxiosError>( ["get_aliases", queryValue], () => @@ -34,6 +44,12 @@ export default function AliasesRoute(): ReactElement { }, ) + useUpdateEffect(() => { + if (!isPressingControl) { + setLockDisabledCopyMode(false) + } + }, [isPressingControl]) + return ( , AxiosError> query={query}> - {result => ( - + {({items: aliases}) => ( + <> + + + {(() => { + if (aliases.length === 0) { + if (searchValue === "") { + return + } else { + return + } + } + + return ( + + {aliases.map(alias => ( + { + copyToClipboard(alias) + setLockDisabledCopyMode(true) + } + : undefined + } + /> + ))} + + ) + })()} + + + + + + + + + + {t("routes.AliasesRoute.isInCopyMode")} + + + )}