Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 91 additions & 61 deletions src/features/profile/Backups.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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'],
Expand All @@ -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 {
Expand Down Expand Up @@ -180,54 +208,56 @@ function BackupItem({ backup }) {
}, [fullBackup])

return (
<ListItem>
<TextField
label={`${t('name')}${
localStorage.getItem('last-loaded') === backup.name ? '*' : ''
}`}
size="small"
value={name || ''}
onChange={(e) => setName(e.target.value)}
variant="outlined"
sx={{ mr: 2 }}
/>
<ButtonGroup variant="outlined" size="small">
<Button
disabled={loading}
color="secondary"
onClick={() => {
load({ variables: { id: backup.id } })
}}
>
{t('load')}
</Button>
<Button
disabled={loading}
color="secondary"
onClick={() => {
update({
variables: {
backup: {
id: backup.id,
name,
data: useStorage.getState(),
},
},
})
}}
>
{t('update')}
</Button>
<Button
disabled={loading}
color="primary"
onClick={() => {
remove({ variables: { id: backup.id } })
<ListItem sx={{ flexDirection: 'column', alignItems: 'stretch' }}>
<Box sx={{ display: 'flex', width: '100%' }}>
<TextField
label={`${t('name')}${
localStorage.getItem('last-loaded') === backup.name ? '*' : ''
}`}
size="small"
value={name || ''}
onChange={(e) => {
setErrorMessage('')
setName(e.target.value)
}}
variant="outlined"
sx={{ mr: 2 }}
/>
<ButtonGroup variant="outlined" size="small">
<Button
disabled={loading}
color="secondary"
onClick={() => {
setErrorMessage('')
load({ variables: { id: backup.id } })
}}
>
{t('load')}
</Button>
<Button disabled={loading} color="secondary" onClick={handleUpdate}>
{t('update')}
</Button>
<Button
disabled={loading}
color="primary"
onClick={() => {
setErrorMessage('')
remove({ variables: { id: backup.id } })
}}
>
{t('delete')}
</Button>
</ButtonGroup>
</Box>
{errorMessage ? (
<Typography
variant="caption"
color="error"
sx={{ mt: 1, alignSelf: 'flex-start' }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the unapproved update-error margin

When an update fails and the error caption is rendered, mt: 1 introduces new top-margin spacing without the user's explicit written permission. Remove this margin or obtain permission before changing the spacing.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

Comment on lines +253 to +256
>
{t('delete')}
</Button>
</ButtonGroup>
{errorMessage}
</Typography>
) : null}
</ListItem>
)
}
63 changes: 63 additions & 0 deletions src/features/profile/backupData.js
Original file line number Diff line number Diff line change
@@ -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<string, any>} state
* @param {Record<string, any>} defaultFilters
*/
export function createBackupData(state, defaultFilters) {
const backup = JSON.parse(JSON.stringify(state))
backup.filters =
getDifference(backup.filters || {}, defaultFilters || {}) || {}
return backup
}
Loading