diff --git a/src/features/profile/Backups.jsx b/src/features/profile/Backups.jsx index 98b656456..78b4ce951 100644 --- a/src/features/profile/Backups.jsx +++ b/src/features/profile/Backups.jsx @@ -21,6 +21,28 @@ import Box from '@mui/material/Box' import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { Query } from '@services/queries' +import { createBackupData } from './backupData' + +/** @param {unknown} err @param {(key: string) => string} t */ +function getBackupErrorMessage(err, t) { + let message = t('backup_error_generic') + if (err instanceof ApolloError) { + const { networkError } = err + if ( + networkError && + 'statusCode' in networkError && + networkError.statusCode === 413 + ) { + message = t('backup_error_too_large') + } else if (err.message) { + message = err.message + } + } + return message +} + +const getCurrentBackupData = () => + createBackupData(useStorage.getState(), useMemory.getState().filters) export function UserBackups() { const { t } = useTranslation() @@ -73,24 +95,11 @@ function CreateNew({ backups }) { setErrorMessage('') try { await create({ - variables: { backup: { name, data: useStorage.getState() } }, + variables: { backup: { name, data: getCurrentBackupData() } }, }) setName('') } catch (err) { - let message = t('backup_error_generic') - if (err instanceof ApolloError) { - const { networkError } = err - if ( - networkError && - 'statusCode' in networkError && - networkError.statusCode === 413 - ) { - message = t('backup_error_too_large') - } else if (err.message) { - message = err.message - } - } - setErrorMessage(message) + setErrorMessage(getBackupErrorMessage(err, t)) } }, [backups, create, loading, name, t, userBackupLimits]) @@ -139,6 +148,7 @@ function BackupItem({ backup }) { const { t } = useTranslation() const [name, setName] = React.useState(backup.name) const [loading, setLoading] = React.useState(false) + const [errorMessage, setErrorMessage] = React.useState('') const [update, { loading: l1 }] = useMutation(Query.user('UPDATE_BACKUP'), { refetchQueries: ['GetBackups'], @@ -153,6 +163,24 @@ function BackupItem({ backup }) { React.useEffect(() => setName(backup.name), [backup]) React.useEffect(() => setLoading(l1 || l2 || l3), [l1, l2, l3]) + const handleUpdate = React.useCallback(async () => { + if (loading) return + setErrorMessage('') + try { + await update({ + variables: { + backup: { + id: backup.id, + name, + data: getCurrentBackupData(), + }, + }, + }) + } catch (err) { + setErrorMessage(getBackupErrorMessage(err, t)) + } + }, [backup.id, loading, name, t, update]) + React.useEffect(() => { if (fullBackup?.backup?.data) { try { @@ -180,54 +208,56 @@ function BackupItem({ backup }) { }, [fullBackup]) return ( - - setName(e.target.value)} - variant="outlined" - sx={{ mr: 2 }} - /> - - - - + + + + + {errorMessage ? ( + - {t('delete')} - - + {errorMessage} + + ) : null} ) } diff --git a/src/features/profile/backupData.js b/src/features/profile/backupData.js new file mode 100644 index 000000000..97f13713b --- /dev/null +++ b/src/features/profile/backupData.js @@ -0,0 +1,63 @@ +// @ts-check + +/** @param {unknown} value */ +const isPlainObject = (value) => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + +/** + * Returns only values that differ from the matching defaults. + * Unknown keys are deliberately retained for forwards/backwards compatibility. + * + * @param {unknown} value + * @param {unknown} defaults + * @returns {unknown} + */ +function getDifference(value, defaults) { + if (Object.is(value, defaults)) return undefined + + if (Array.isArray(value) && Array.isArray(defaults)) { + if ( + value.length === defaults.length && + value.every((entry, index) => + Object.is(getDifference(entry, defaults[index]), undefined), + ) + ) { + return undefined + } + return value + } + + if (isPlainObject(value) && isPlainObject(defaults)) { + const difference = {} + Object.entries(value).forEach(([key, entry]) => { + const entryDifference = Object.prototype.hasOwnProperty.call( + defaults, + key, + ) + ? getDifference(entry, defaults[key]) + : entry + if (entryDifference !== undefined) difference[key] = entryDifference + }) + return Object.keys(difference).length ? difference : undefined + } + + return value +} + +/** + * Produces a JSON-safe profile payload. Filter values matching the current + * server defaults are omitted because useMapData merges those defaults back in + * when a profile is loaded. + * + * @param {Record} state + * @param {Record} defaultFilters + */ +export function createBackupData(state, defaultFilters) { + const backup = JSON.parse(JSON.stringify(state)) + backup.filters = + getDifference(backup.filters || {}, defaultFilters || {}) || {} + return backup +}