diff --git a/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx b/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx index e0759f6d8c..a7d77188ce 100644 --- a/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx +++ b/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx @@ -192,7 +192,7 @@ export const ListDrawerContent: React.FC = ({ sort, } - setPreference(preferenceKey, newPreferences) + setPreference(preferenceKey, newPreferences, true) }, [sort, limit, setPreference, preferenceKey]) const onCreateNew = useCallback( diff --git a/packages/payload/src/admin/components/elements/TableColumns/index.tsx b/packages/payload/src/admin/components/elements/TableColumns/index.tsx index 874d65e505..0857fc08b5 100644 --- a/packages/payload/src/admin/components/elements/TableColumns/index.tsx +++ b/packages/payload/src/admin/components/elements/TableColumns/index.tsx @@ -122,22 +122,12 @@ export const TableColumnsProvider: React.FC<{ useEffect(() => { if (!hasInitialized.current) return + const columns = tableColumns.map((c) => ({ + accessor: c.accessor, + active: c.active, + })) - const sync = async () => { - const currentPreferences = await getPreference(preferenceKey) - - const newPreferences = { - ...currentPreferences, - columns: tableColumns.map((c) => ({ - accessor: c.accessor, - active: c.active, - })), - } - - setPreference(preferenceKey, newPreferences) - } - - sync() + void setPreference(preferenceKey, { columns }, true) }, [tableColumns, preferenceKey, setPreference, getPreference]) const setActiveColumns = useCallback( diff --git a/packages/payload/src/admin/components/utilities/Preferences/index.tsx b/packages/payload/src/admin/components/utilities/Preferences/index.tsx index 8888bf678b..3921a2fdee 100644 --- a/packages/payload/src/admin/components/utilities/Preferences/index.tsx +++ b/packages/payload/src/admin/components/utilities/Preferences/index.tsx @@ -1,3 +1,4 @@ +import isDeepEqual from 'deep-equal' import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -7,7 +8,12 @@ import { useConfig } from '../Config' type PreferencesContext = { getPreference: (key: string) => Promise | T - setPreference: (key: string, value: T) => Promise + /** + * @param key - a string identifier for the property being set + * @param value - preference data to store + * @param merge - when true will combine the existing preference object batch the change into one request for objects, default = false + */ + setPreference: (key: string, value: T, merge?: boolean) => Promise } const Context = createContext({} as PreferencesContext) @@ -23,6 +29,7 @@ const requestOptions = (value, language) => ({ export const PreferencesProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const contextRef = useRef({} as PreferencesContext) const preferencesRef = useRef({}) + const pendingUpdate = useRef({}) const config = useConfig() const { user } = useAuth() const { i18n } = useTranslation() @@ -43,7 +50,7 @@ export const PreferencesProvider: React.FC<{ children?: React.ReactNode }> = ({ const prefs = preferencesRef.current if (typeof prefs[key] !== 'undefined') return prefs[key] const promise = new Promise((resolve: (value: T) => void) => { - ;(async () => { + void (async () => { const request = await requests.get(`${serverURL}${api}/payload-preferences/${key}`, { headers: { 'Accept-Language': i18n.language, @@ -65,14 +72,63 @@ export const PreferencesProvider: React.FC<{ children?: React.ReactNode }> = ({ ) const setPreference = useCallback( - async (key: string, value: unknown): Promise => { - preferencesRef.current[key] = value - await requests.post( - `${serverURL}${api}/payload-preferences/${key}`, - requestOptions(value, i18n.language), - ) + async (key: string, value: unknown, merge = false): Promise => { + if (merge === false) { + preferencesRef.current[key] = value + await requests.post( + `${serverURL}${api}/payload-preferences/${key}`, + requestOptions(value, i18n.language), + ) + return + } + + let newValue = value + const currentPreference = await getPreference(key) + // handle value objects where multiple values can be set under one key + if ( + typeof value === 'object' && + typeof currentPreference === 'object' && + typeof newValue === 'object' + ) { + // merge the value with any existing preference for the key + newValue = { ...(currentPreference || {}), ...value } + if (isDeepEqual(newValue, currentPreference)) { + return + } + // add the requested changes to a pendingUpdate batch for the key + pendingUpdate.current[key] = { + ...pendingUpdate.current[key], + ...(newValue as Record), + } + } else { + if (newValue === currentPreference) { + return + } + pendingUpdate.current[key] = newValue + } + + const updatePreference = async () => { + // compare the value stored in context before sending to eliminate duplicate requests + if (isDeepEqual(pendingUpdate.current[key], preferencesRef.current[key])) { + return + } + // preference set in context here to prevent other updatePreference at the same time + preferencesRef.current[key] = pendingUpdate.current[key] + + await requests.post( + `${serverURL}${api}/payload-preferences/${key}`, + requestOptions(preferencesRef.current[key], i18n.language), + ) + // reset any changes for this key after sending the request + delete pendingUpdate.current[key] + } + + // use timeout to allow multiple changes of different values using the same key in one request + setTimeout(() => { + void updatePreference() + }) }, - [api, i18n.language, serverURL], + [api, getPreference, i18n.language, pendingUpdate, serverURL], ) contextRef.current.getPreference = getPreference diff --git a/packages/payload/src/admin/components/views/collections/List/index.tsx b/packages/payload/src/admin/components/views/collections/List/index.tsx index dad3ae9097..ae57a8777d 100644 --- a/packages/payload/src/admin/components/views/collections/List/index.tsx +++ b/packages/payload/src/admin/components/views/collections/List/index.tsx @@ -172,18 +172,12 @@ const ListView: React.FC = (props) => { // ///////////////////////////////////// useEffect(() => { - ;(async () => { - const currentPreferences = await getPreference(preferenceKey) + void setPreference(preferenceKey, { sort }, true) + }, [sort, preferenceKey, setPreference]) - const newPreferences = { - ...currentPreferences, - limit, - sort, - } - - setPreference(preferenceKey, newPreferences) - })() - }, [sort, limit, preferenceKey, setPreference, getPreference]) + useEffect(() => { + void setPreference(preferenceKey, { limit }, true) + }, [limit, preferenceKey, setPreference]) // ///////////////////////////////////// // Prevent going beyond page limit diff --git a/packages/payload/src/preferences/operations/update.ts b/packages/payload/src/preferences/operations/update.ts index 2619004a60..08caa2fbca 100644 --- a/packages/payload/src/preferences/operations/update.ts +++ b/packages/payload/src/preferences/operations/update.ts @@ -39,20 +39,20 @@ async function update(args: PreferenceUpdateRequest) { await executeAccess({ req }, defaultAccess) } - // TODO: workaround to prevent race-conditions 500 errors from violating unique constraints try { - await payload.db.create({ - collection, - data: preference, - req, - }) - } catch (err: unknown) { + // try/catch because we attempt to update without first reading to check if it exists first to save on db calls await payload.db.updateOne({ collection, data: preference, req, where: filter, }) + } catch (err: unknown) { + await payload.db.create({ + collection, + data: preference, + req, + }) } return preference diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index c0037f8d6e..1d7259ef0c 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -350,6 +350,95 @@ describe('Auth', () => { expect(afterToken).toBeNull() }) + describe('User Preferences', () => { + const key = 'test' + const property = 'store' + let data + + beforeAll(async () => { + const response = await fetch(`${apiUrl}/payload-preferences/${key}`, { + body: JSON.stringify({ + value: { property }, + }), + headers: { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }, + method: 'post', + }) + data = await response.json() + }) + + it('should create', async () => { + expect(data.doc.key).toStrictEqual(key) + expect(data.doc.value.property).toStrictEqual(property) + }) + + it('should read', async () => { + const response = await fetch(`${apiUrl}/payload-preferences/${key}`, { + headers: { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }, + method: 'get', + }) + data = await response.json() + expect(data.key).toStrictEqual(key) + expect(data.value.property).toStrictEqual(property) + }) + + it('should update', async () => { + const response = await fetch(`${apiUrl}/payload-preferences/${key}`, { + body: JSON.stringify({ + value: { property: 'updated', property2: 'test' }, + }), + headers: { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }, + method: 'post', + }) + + data = await response.json() + + const result = await payload.find({ + collection: 'payload-preferences', + depth: 0, + where: { + key: { equals: key }, + }, + }) + + expect(data.doc.key).toStrictEqual(key) + expect(data.doc.value.property).toStrictEqual('updated') + expect(data.doc.value.property2).toStrictEqual('test') + + expect(result.docs).toHaveLength(1) + }) + + it('should delete', async () => { + const response = await fetch(`${apiUrl}/payload-preferences/${key}`, { + headers: { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }, + method: 'delete', + }) + + data = await response.json() + + const result = await payload.find({ + collection: 'payload-preferences', + depth: 0, + where: { + key: { equals: key }, + }, + }) + + expect(result.docs).toHaveLength(0) + }) + }) + describe('Account Locking', () => { const userEmail = 'lock@me.com'