diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index a2af698ea..1a019e48a 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -2,13 +2,11 @@ import type { AdminViewServerProps, CollectionPreferences, ColumnPreference, - DefaultDocumentIDType, ListQuery, ListViewClientProps, ListViewServerPropsOnly, QueryPreset, SanitizedCollectionPermission, - Where, } from 'payload' import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui' @@ -20,6 +18,7 @@ import { isNumber, mergeListSearchAndWhere, transformColumnsToPreferences, + transformColumnsToSearchParams, } from 'payload/shared' import React, { Fragment } from 'react' @@ -87,28 +86,33 @@ export const renderListView = async ( throw new Error('not-found') } - const query = queryFromArgs || queryFromReq + const query: ListQuery = queryFromArgs || queryFromReq - const columns: ColumnPreference[] = transformColumnsToPreferences( - query?.columns as ColumnPreference[] | string, - ) + const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns) - /** - * @todo: find a pattern to avoid setting preferences on hard navigation, i.e. direct links, page refresh, etc. - * This will ensure that prefs are only updated when explicitly set by the user - * This could potentially be done by injecting a `sessionID` into the params and comparing it against a session cookie - */ const collectionPreferences = await upsertPreferences({ key: `collection-${collectionSlug}`, req, value: { - columns, + columns: columnsFromQuery, limit: isNumber(query?.limit) ? Number(query.limit) : undefined, - preset: (query?.preset as DefaultDocumentIDType) || null, + preset: query?.preset, sort: query?.sort as string, }, }) + query.preset = collectionPreferences?.preset + + query.page = isNumber(query?.page) ? Number(query.page) : 0 + + query.limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit + + query.sort = + collectionPreferences?.sort || + (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined) + + query.columns = transformColumnsToSearchParams(collectionPreferences?.columns || []) + const { routes: { admin: adminRoute }, } = config @@ -118,35 +122,27 @@ export const renderListView = async ( throw new Error('not-found') } - const page = isNumber(query?.page) ? Number(query.page) : 0 - - const limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit - - const sort = - collectionPreferences?.sort || - (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined) - - let where = mergeListSearchAndWhere({ - collectionConfig, - search: typeof query?.search === 'string' ? query.search : undefined, - where: (query?.where as Where) || undefined, - }) - if (typeof collectionConfig.admin?.baseListFilter === 'function') { const baseListFilter = await collectionConfig.admin.baseListFilter({ - limit, - page, + limit: query.limit, + page: query.page, req, - sort, + sort: query.sort, }) if (baseListFilter) { - where = { - and: [where, baseListFilter].filter(Boolean), + query.where = { + and: [query.where, baseListFilter].filter(Boolean), } } } + const whereWithMergedSearch = mergeListSearchAndWhere({ + collectionConfig, + search: typeof query?.search === 'string' ? query.search : undefined, + where: query?.where, + }) + let queryPreset: QueryPreset | undefined let queryPresetPermissions: SanitizedCollectionPermission | undefined @@ -179,14 +175,14 @@ export const renderListView = async ( draft: true, fallbackLocale: false, includeLockStatus: true, - limit, + limit: query.limit, locale, overrideAccess: false, - page, + page: query.page, req, - sort, + sort: query.sort, user, - where: where || {}, + where: whereWithMergedSearch, }) const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug) @@ -194,8 +190,7 @@ export const renderListView = async ( const { columnState, Table } = renderTable({ clientCollectionConfig, collectionConfig, - columnPreferences: collectionPreferences?.columns, - columns, + columns: collectionPreferences?.columns, customCellProps, docs: data.docs, drawerSlug, @@ -232,7 +227,7 @@ export const renderListView = async ( collectionConfig, data, i18n, - limit, + limit: query.limit, listPreferences: collectionPreferences, listSearchableFields: collectionConfig.admin.listSearchableFields, locale: fullLocale, @@ -258,19 +253,19 @@ export const renderListView = async ( const isInDrawer = Boolean(drawerSlug) + // Needed to prevent: Only plain objects can be passed to Client Components from Server Components. Objects with toJSON methods are not supported. Convert it manually to a simple value before passing it to props. + query.where = query?.where ? JSON.parse(JSON.stringify(query?.where || {})) : undefined + return { List: ( {RenderServerComponent({ clientProps: { diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx index c4a564cf4..65eb60913 100644 --- a/packages/next/src/views/Versions/index.tsx +++ b/packages/next/src/views/Versions/index.tsx @@ -148,10 +148,12 @@ export async function VersionsView(props: DocumentViewServerProps) { { let columnsToTransform = columns @@ -44,5 +44,5 @@ export const transformColumnsToPreferences = ( export const transformColumnsToSearchParams = ( columns: Column[] | ColumnPreference[], ): ColumnsFromURL => { - return columns.map((col) => (col.active ? col.accessor : `-${col.accessor}`)) + return columns?.map((col) => (col.active ? col.accessor : `-${col.accessor}`)) } diff --git a/packages/payload/src/utilities/validateWhereQuery.ts b/packages/payload/src/utilities/validateWhereQuery.ts index cb920db1a..720aa66a1 100644 --- a/packages/payload/src/utilities/validateWhereQuery.ts +++ b/packages/payload/src/utilities/validateWhereQuery.ts @@ -13,9 +13,10 @@ import { validOperatorSet } from '../types/constants.js' export const validateWhereQuery = (whereQuery: Where): whereQuery is Where => { if ( whereQuery?.or && - whereQuery?.or?.length > 0 && - whereQuery?.or?.[0]?.and && - whereQuery?.or?.[0]?.and?.length > 0 + (whereQuery?.or?.length === 0 || + (whereQuery?.or?.length > 0 && + whereQuery?.or?.[0]?.and && + whereQuery?.or?.[0]?.and?.length > 0)) ) { // At this point we know that the whereQuery has 'or' and 'and' fields, // now let's check the structure and content of these fields. diff --git a/packages/ui/src/elements/ListControls/useQueryPresets.tsx b/packages/ui/src/elements/ListControls/useQueryPresets.tsx index 694d6f68d..587eac678 100644 --- a/packages/ui/src/elements/ListControls/useQueryPresets.tsx +++ b/packages/ui/src/elements/ListControls/useQueryPresets.tsx @@ -3,7 +3,7 @@ import type { CollectionSlug, QueryPreset, SanitizedCollectionPermission } from import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' import { transformColumnsToPreferences, transformColumnsToSearchParams } from 'payload/shared' -import React, { Fragment, useCallback, useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { useConfig } from '../../providers/Config/index.js' @@ -103,9 +103,9 @@ export const useQueryPresets = ({ const resetQueryPreset = useCallback(async () => { await refineListData( { - columns: undefined, - preset: undefined, - where: undefined, + columns: [], + preset: '', + where: {}, }, false, ) diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx index 80fdad7d9..a8080fb72 100644 --- a/packages/ui/src/elements/RelationshipTable/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -114,7 +114,7 @@ export const RelationshipTable: React.FC = (pro const renderTable = useCallback( async (docs?: PaginatedDocs['docs']) => { const newQuery: ListQuery = { - limit: String(field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit), + limit: field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit, sort: field.defaultSort || collectionConfig?.defaultSort, ...(query || {}), where: { ...(query?.where || {}) }, @@ -240,6 +240,15 @@ export const RelationshipTable: React.FC = (pro // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDrawerOpen]) + const memoizedQuery = React.useMemo( + () => ({ + columns: transformColumnsToPreferences(columnState)?.map(({ accessor }) => accessor), + limit: field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit, + sort: field.defaultSort ?? collectionConfig?.defaultSort, + }), + [field, columnState, collectionConfig], + ) + return (
@@ -306,12 +315,7 @@ export const RelationshipTable: React.FC = (pro {data?.docs && data.docs.length > 0 && ( = (pro ? undefined : `_${field.collection}_${fieldPath.replaceAll('.', '_')}_order` } + query={memoizedQuery} > = ({ children, collectionSlug, - columns, data, - defaultLimit, - defaultSort, - listPreferences, modifySearchParams, onQueryChange: onQueryChangeFromProps, orderableFieldName, + query: queryFromProps, }) => { // TODO: Investigate if this is still needed - // eslint-disable-next-line react-compiler/react-compiler + 'use no memo' const router = useRouter() const rawSearchParams = useSearchParams() @@ -36,7 +34,7 @@ export const ListQueryProvider: React.FC = ({ const [modified, setModified] = useState(false) const searchParams = useMemo( - () => parseSearchParams(rawSearchParams), + () => sanitizeQuery(parseSearchParams(rawSearchParams)), [rawSearchParams], ) @@ -51,37 +49,12 @@ export const ListQueryProvider: React.FC = ({ return searchParams } else { return { - limit: String(defaultLimit), - sort: defaultSort, + limit: queryFromProps.limit, + sort: queryFromProps.sort, } } }) - const mergeQuery = useCallback( - (newQuery: ListQuery = {}): ListQuery => { - let page = 'page' in newQuery ? newQuery.page : currentQuery?.page - - if ('where' in newQuery || 'search' in newQuery) { - page = '1' - } - - const mergedQuery: ListQuery = { - ...currentQuery, - ...newQuery, - columns: 'columns' in newQuery ? newQuery.columns : currentQuery.columns, - limit: 'limit' in newQuery ? newQuery.limit : (currentQuery?.limit ?? String(defaultLimit)), - page, - preset: 'preset' in newQuery ? newQuery.preset : currentQuery?.preset, - search: 'search' in newQuery ? newQuery.search : currentQuery?.search, - sort: 'sort' in newQuery ? newQuery.sort : ((currentQuery?.sort as string) ?? defaultSort), - where: 'where' in newQuery ? newQuery.where : currentQuery?.where, - } - - return mergedQuery - }, - [currentQuery, defaultLimit, defaultSort], - ) - const refineListData = useCallback( // eslint-disable-next-line @typescript-eslint/require-await async (incomingQuery: ListQuery, modified?: boolean) => { @@ -91,12 +64,23 @@ export const ListQueryProvider: React.FC = ({ setModified(true) } - const newQuery = mergeQuery(incomingQuery) + const newQuery = mergeQuery(currentQuery, incomingQuery, { + defaults: { + limit: queryFromProps.limit, + sort: queryFromProps.sort, + }, + }) if (modifySearchParams) { startRouteTransition(() => router.replace( - `${qs.stringify({ ...newQuery, columns: JSON.stringify(newQuery.columns) }, { addQueryPrefix: true })}`, + `${qs.stringify( + { + ...newQuery, + columns: JSON.stringify(newQuery.columns), + }, + { addQueryPrefix: true }, + )}`, ), ) } else if ( @@ -110,7 +94,9 @@ export const ListQueryProvider: React.FC = ({ setCurrentQuery(newQuery) }, [ - mergeQuery, + currentQuery, + queryFromProps.limit, + queryFromProps.sort, modifySearchParams, onQueryChange, onQueryChangeFromProps, @@ -121,14 +107,14 @@ export const ListQueryProvider: React.FC = ({ const handlePageChange = useCallback( async (arg: number) => { - await refineListData({ page: String(arg) }) + await refineListData({ page: arg }) }, [refineListData], ) const handlePerPageChange = React.useCallback( async (arg: number) => { - await refineListData({ limit: String(arg), page: '1' }) + await refineListData({ limit: arg, page: 1 }) }, [refineListData], ) @@ -155,47 +141,26 @@ export const ListQueryProvider: React.FC = ({ [refineListData], ) - const syncQuery = useEffectEvent(() => { - let shouldUpdateQueryString = false - const newQuery = { ...(currentQuery || {}) } + const mergeQueryFromPropsAndSyncToURL = useEffectEvent(() => { + const newQuery = sanitizeQuery({ ...(currentQuery || {}), ...(queryFromProps || {}) }) - // Allow the URL to override the default limit - if (isNumber(defaultLimit) && !('limit' in currentQuery)) { - newQuery.limit = String(defaultLimit) - shouldUpdateQueryString = true - } + const search = `?${qs.stringify({ ...newQuery, columns: JSON.stringify(newQuery.columns) })}` - // Allow the URL to override the default sort - if (defaultSort && !('sort' in currentQuery)) { - newQuery.sort = defaultSort - shouldUpdateQueryString = true - } - - // Only modify columns if they originated from preferences - // We can assume they did if `listPreferences.columns` is defined - if (columns && listPreferences?.columns && !('columns' in currentQuery)) { - newQuery.columns = transformColumnsToSearchParams(columns) - shouldUpdateQueryString = true - } - - if (shouldUpdateQueryString) { + if (window.location.search !== search) { setCurrentQuery(newQuery) - // Do not use router.replace here to avoid re-rendering on initial load - window.history.replaceState( - null, - '', - `?${qs.stringify({ ...newQuery, columns: JSON.stringify(newQuery.columns) })}`, - ) + + // Important: do not use router.replace here to avoid re-rendering on initial load + window.history.replaceState(null, '', search) } }) - // If `defaultLimit` or `defaultSort` are updated externally, update the query - // I.e. when HMR runs, these properties may be different + // If `query` is updated externally, update the local state + // E.g. when HMR runs, these properties may be different useEffect(() => { if (modifySearchParams) { - syncQuery() + mergeQueryFromPropsAndSyncToURL() } - }, [defaultSort, defaultLimit, modifySearchParams, columns]) + }, [modifySearchParams, queryFromProps]) return ( { + let page = 'page' in newQuery ? newQuery.page : currentQuery?.page + + if ('where' in newQuery || 'search' in newQuery) { + page = 1 + } + + const mergedQuery: ListQuery = { + ...currentQuery, + ...newQuery, + columns: 'columns' in newQuery ? newQuery.columns : currentQuery.columns, + groupBy: + 'groupBy' in newQuery + ? newQuery.groupBy + : (currentQuery?.groupBy ?? options?.defaults?.groupBy), + limit: 'limit' in newQuery ? newQuery.limit : (currentQuery?.limit ?? options?.defaults?.limit), + page, + preset: 'preset' in newQuery ? newQuery.preset : currentQuery?.preset, + search: 'search' in newQuery ? newQuery.search : currentQuery?.search, + sort: + 'sort' in newQuery + ? newQuery.sort + : ((currentQuery?.sort as string) ?? options?.defaults?.sort), + where: 'where' in newQuery ? newQuery.where : currentQuery?.where, + } + + return mergedQuery +} diff --git a/packages/ui/src/providers/ListQuery/sanitizeQuery.ts b/packages/ui/src/providers/ListQuery/sanitizeQuery.ts new file mode 100644 index 000000000..551ddf459 --- /dev/null +++ b/packages/ui/src/providers/ListQuery/sanitizeQuery.ts @@ -0,0 +1,38 @@ +import type { ListQuery, Where } from 'payload' + +/** + * Sanitize empty strings from the query, e.g. `?preset=` + * This is how we determine whether to clear user preferences for certain params + * Once cleared, they are no longer needed in the URL + */ +export const sanitizeQuery = (toSanitize: ListQuery): ListQuery => { + const sanitized = { ...toSanitize } + + Object.entries(sanitized).forEach(([key, value]) => { + if ( + key === 'columns' && + (value === '[]' || (Array.isArray(sanitized[key]) && sanitized[key].length === 0)) + ) { + delete sanitized[key] + } + + if (key === 'where' && typeof value === 'object' && !Object.keys(value as Where).length) { + delete sanitized[key] + } + + if ((key === 'limit' || key === 'page') && typeof value === 'string') { + const parsed = parseInt(value, 10) + sanitized[key] = Number.isNaN(parsed) ? undefined : parsed + } + + if (key === 'page' && value === 0) { + delete sanitized[key] + } + + if (value === '') { + delete sanitized[key] + } + }) + + return sanitized +} diff --git a/packages/ui/src/providers/ListQuery/types.ts b/packages/ui/src/providers/ListQuery/types.ts index ec9192302..ea009b2a5 100644 --- a/packages/ui/src/providers/ListQuery/types.ts +++ b/packages/ui/src/providers/ListQuery/types.ts @@ -1,6 +1,5 @@ import type { ClientCollectionConfig, - CollectionPreferences, ColumnPreference, ListQuery, PaginatedDocs, @@ -21,11 +20,7 @@ export type OnListQueryChange = (query: ListQuery) => void export type ListQueryProps = { readonly children: React.ReactNode readonly collectionSlug?: ClientCollectionConfig['slug'] - readonly columns?: ColumnPreference[] readonly data: PaginatedDocs - readonly defaultLimit?: number - readonly defaultSort?: Sort - readonly listPreferences?: CollectionPreferences readonly modifySearchParams?: boolean readonly onQueryChange?: OnListQueryChange readonly orderableFieldName?: string @@ -33,6 +28,7 @@ export type ListQueryProps = { * @deprecated */ readonly preferenceKey?: string + query?: ListQuery } export type IListQueryContext = { diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx b/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx index 48c39b0e9..bf23d0c5d 100644 --- a/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx +++ b/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx @@ -38,7 +38,6 @@ import { sortFieldMap } from './sortFieldMap.js' export type BuildColumnStateArgs = { beforeRows?: Column[] clientFields: ClientField[] - columnPreferences: CollectionPreferences['columns'] columns?: CollectionPreferences['columns'] customCellProps: DefaultCellComponentProps['customCellProps'] enableLinkedCell?: boolean @@ -70,7 +69,6 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { beforeRows, clientFields, collectionSlug, - columnPreferences, columns, customCellProps, dataType, @@ -99,7 +97,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { // place the `ID` field first, if it exists // do the same for the `useAsTitle` field with precedence over the `ID` field - // then sort the rest of the fields based on the `defaultColumns` or `columnPreferences` + // then sort the rest of the fields based on the `defaultColumns` or `columns` const idFieldIndex = sortedFieldMap?.findIndex((field) => fieldIsID(field)) if (idFieldIndex > -1) { @@ -116,10 +114,10 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { sortedFieldMap.unshift(useAsTitleField) } - const sortTo = columnPreferences || columns + const sortTo = columns if (sortTo) { - // sort the fields to the order of `defaultColumns` or `columnPreferences` + // sort the fields to the order of `defaultColumns` or `columns` sortedFieldMap = sortFieldMap(sortedFieldMap, sortTo) _sortedFieldMap = sortFieldMap(_sortedFieldMap, sortTo) // TODO: think of a way to avoid this additional sort } @@ -150,14 +148,14 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { return acc // skip any group without a custom cell } - const columnPreference = columnPreferences?.find( + const columnPref = columns?.find( (preference) => clientField && 'name' in clientField && preference.accessor === accessor, ) const isActive = isColumnActive({ accessor, activeColumnsIndices, - columnPreference, + column: columnPref, columns, }) diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/isColumnActive.ts b/packages/ui/src/providers/TableColumns/buildColumnState/isColumnActive.ts index 517fbb5c2..52561546d 100644 --- a/packages/ui/src/providers/TableColumns/buildColumnState/isColumnActive.ts +++ b/packages/ui/src/providers/TableColumns/buildColumnState/isColumnActive.ts @@ -3,18 +3,18 @@ import type { ColumnPreference } from 'payload' export function isColumnActive({ accessor, activeColumnsIndices, - columnPreference, + column, columns, }: { accessor: string activeColumnsIndices: number[] - columnPreference: ColumnPreference + column: ColumnPreference columns: ColumnPreference[] }) { - if (columnPreference) { - return columnPreference.active + if (column) { + return column.active } else if (columns && Array.isArray(columns) && columns.length > 0) { - return Boolean(columns.find((column) => column.accessor === accessor)?.active) + return Boolean(columns.find((col) => col.accessor === accessor)?.active) } else if (activeColumnsIndices.length < 4) { return true } diff --git a/packages/ui/src/utilities/buildTableState.ts b/packages/ui/src/utilities/buildTableState.ts index e2b436f9c..c76fb6e32 100644 --- a/packages/ui/src/utilities/buildTableState.ts +++ b/packages/ui/src/utilities/buildTableState.ts @@ -215,10 +215,10 @@ const buildTableState = async ( collection: collectionSlug, depth: 0, draft: true, - limit: query?.limit ? parseInt(query.limit, 10) : undefined, + limit: query?.limit, locale: req.locale, overrideAccess: false, - page: query?.page ? parseInt(query.page, 10) : undefined, + page: query?.page, sort: query?.sort, user: req.user, where: query?.where, @@ -232,7 +232,6 @@ const buildTableState = async ( clientConfig, collectionConfig, collections: Array.isArray(collectionSlug) ? collectionSlug : undefined, - columnPreferences: Array.isArray(collectionSlug) ? collectionPreferences?.columns : undefined, // TODO, might not be neededcolumns, columns, docs, enableRowSelections, diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index d0b021611..9a3fe5716 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -64,7 +64,6 @@ export const renderTable = ({ clientConfig, collectionConfig, collections, - columnPreferences, columns: columnsFromArgs, customCellProps, docs, @@ -80,7 +79,6 @@ export const renderTable = ({ clientConfig?: ClientConfig collectionConfig?: SanitizedCollectionConfig collections?: string[] - columnPreferences: CollectionPreferences['columns'] columns?: CollectionPreferences['columns'] customCellProps?: Record docs: PaginatedDocs['docs'] @@ -154,7 +152,6 @@ export const renderTable = ({ const sharedArgs: Pick< BuildColumnStateArgs, | 'clientFields' - | 'columnPreferences' | 'columns' | 'customCellProps' | 'enableRowSelections' @@ -164,7 +161,6 @@ export const renderTable = ({ | 'useAsTitle' > = { clientFields, - columnPreferences, columns, enableRowSelections, i18n, diff --git a/packages/ui/src/utilities/upsertPreferences.ts b/packages/ui/src/utilities/upsertPreferences.ts index 26e51ac81..477d98a04 100644 --- a/packages/ui/src/utilities/upsertPreferences.ts +++ b/packages/ui/src/utilities/upsertPreferences.ts @@ -5,13 +5,26 @@ import { cache } from 'react' import { removeUndefined } from './removeUndefined.js' +type PreferenceDoc = { + id: DefaultDocumentIDType | undefined + value?: T | undefined +} + +type DefaultMerge = (existingValue: T, incomingValue: T | undefined) => T + +const defaultMerge: DefaultMerge = (existingValue: T, incomingValue: T | undefined) => + ({ + ...(typeof existingValue === 'object' ? existingValue : {}), // Shallow merge existing prefs to acquire any missing keys from incoming value + ...removeUndefined(incomingValue || {}), + }) as T + export const getPreferences = cache( async ( key: string, payload: Payload, userID: DefaultDocumentIDType, userSlug: string, - ): Promise<{ id: DefaultDocumentIDType; value: T }> => { + ): Promise> => { const result = (await payload .find({ collection: 'payload-preferences', @@ -58,21 +71,14 @@ export const upsertPreferences = async | stri req, value: incomingValue, }: { + customMerge?: (existingValue: T, incomingValue: T, defaultMerge: DefaultMerge) => T key: string req: PayloadRequest -} & ( - | { - customMerge: (existingValue: T) => T - value?: never - } - | { - customMerge?: never - value: T - } -)): Promise => { - const existingPrefs: { id?: DefaultDocumentIDType; value?: T } = req.user + value: T +}): Promise => { + const existingPrefs: PreferenceDoc = req.user ? await getPreferences(key, req.payload, req.user.id, req.user.collection) - : {} + : ({} as PreferenceDoc) let newPrefs = existingPrefs?.value @@ -95,15 +101,12 @@ export const upsertPreferences = async | stri let mergedPrefs: T if (typeof customMerge === 'function') { - mergedPrefs = customMerge(existingPrefs.value) + mergedPrefs = customMerge(existingPrefs.value, incomingValue, defaultMerge) } else { // Strings are valid JSON, i.e. `locale` saved as a string to the locale preferences mergedPrefs = typeof incomingValue === 'object' - ? ({ - ...(typeof existingPrefs.value === 'object' ? existingPrefs?.value : {}), // Shallow merge existing prefs to acquire any missing keys from incoming value - ...removeUndefined(incomingValue || {}), - } as T) + ? defaultMerge(existingPrefs.value, incomingValue) : incomingValue } diff --git a/test/eslint.config.js b/test/eslint.config.js index 2621eb0a6..8cdafaa8f 100644 --- a/test/eslint.config.js +++ b/test/eslint.config.js @@ -74,6 +74,7 @@ export const testEslintConfig = [ 'expectNoResultsAndCreateFolderButton', 'createFolder', 'createFolderFromDoc', + 'assertURLParams', ], }, ], diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 352cb40db..8ee74af84 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -549,6 +549,14 @@ export interface BlockField { } )[] | null; + readOnly?: + | { + title?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'readOnlyBlock'; + }[] + | null; updatedAt: string; createdAt: string; } @@ -2222,6 +2230,17 @@ export interface BlockFieldsSelect { blockName?: T; }; }; + readOnly?: + | T + | { + readOnlyBlock?: + | T + | { + title?: T; + id?: T; + blockName?: T; + }; + }; updatedAt?: T; createdAt?: T; } diff --git a/test/query-presets/e2e.spec.ts b/test/query-presets/e2e.spec.ts index 14ded55fc..317c96a2d 100644 --- a/test/query-presets/e2e.spec.ts +++ b/test/query-presets/e2e.spec.ts @@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test' import { devUser } from 'credentials.js' import { openListColumns } from 'helpers/e2e/openListColumns.js' import { toggleColumn } from 'helpers/e2e/toggleColumn.js' +import { openNav } from 'helpers/e2e/toggleNav.js' import * as path from 'path' import { fileURLToPath } from 'url' @@ -152,23 +153,38 @@ describe('Query Presets', () => { test('should select preset and apply filters', async () => { await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seededData.everyone.title }) await assertURLParams({ page, columns: seededData.everyone.columns, - where: seededData.everyone.where, - presetID: everyoneID, + preset: everyoneID, }) - - expect(true).toBe(true) }) test('should clear selected preset and reset filters', async () => { await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seededData.everyone.title }) + await clearSelectedPreset({ page }) - expect(true).toBe(true) + + // ensure that the preset was cleared from preferences by navigating without the `?preset=` param + // e.g. do not do `page.reload()` + await page.goto(pagesUrl.list) + + // poll url to ensure that `?preset=` param is not present + // this is first set to an empty string to clear from the user's preferences + // it is then removed entirely after it is processed on the server + const regex = /preset=/ + await page.waitForURL((url) => !regex.test(url.search), { timeout: TEST_TIMEOUT_LONG }) + + await expect( + page.locator('button#select-preset', { + hasText: exactText('Select Preset'), + }), + ).toBeVisible() }) test('should delete a preset, clear selection, and reset changes', async () => { @@ -205,18 +221,29 @@ describe('Query Presets', () => { test('should save last used preset to preferences and load on initial render', async () => { await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seededData.everyone.title }) - await page.reload() + await page.goto(pagesUrl.list) await assertURLParams({ page, columns: seededData.everyone.columns, where: seededData.everyone.where, - // presetID: everyoneID, + preset: everyoneID, }) - expect(true).toBe(true) + // for good measure, also soft navigate away and back + await page.goto(pagesUrl.admin) + await openNav(page) + await page.click(`a[href="/admin/collections/${pagesSlug}"]`) + + await assertURLParams({ + page, + columns: seededData.everyone.columns, + where: seededData.everyone.where, + preset: everyoneID, + }) }) test('should only show "edit" and "delete" controls when there is an active preset', async () => { diff --git a/test/query-presets/helpers/assertURLParams.ts b/test/query-presets/helpers/assertURLParams.ts index 2e9bf1b16..36e6653d9 100644 --- a/test/query-presets/helpers/assertURLParams.ts +++ b/test/query-presets/helpers/assertURLParams.ts @@ -10,12 +10,12 @@ export async function assertURLParams({ page, columns, where, - presetID, + preset, }: { columns?: ColumnPreference[] page: Page - presetID?: string | undefined - where: Where + preset?: string | undefined + where?: Where }) { if (where) { // TODO: can't get columns to encode correctly @@ -32,8 +32,8 @@ export async function assertURLParams({ await page.waitForURL(columnsRegex) } - if (presetID) { - const presetRegex = new RegExp(`preset=${presetID}`) + if (preset) { + const presetRegex = new RegExp(`preset=${preset}`) await page.waitForURL(presetRegex) } } diff --git a/test/query-presets/payload-types.ts b/test/query-presets/payload-types.ts index b1c23df8e..b81b0e04e 100644 --- a/test/query-presets/payload-types.ts +++ b/test/query-presets/payload-types.ts @@ -154,6 +154,13 @@ export interface User { hash?: string | null; loginAttempts?: number | null; lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; password?: string | null; } /** @@ -301,6 +308,13 @@ export interface UsersSelect { hash?: T; loginAttempts?: T; lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; } /** * This interface was referenced by `Config`'s JSON-Schema diff --git a/tsconfig.base.json b/tsconfig.base.json index 0898ad390..153abb8a5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,8 +21,15 @@ "skipLibCheck": true, "emitDeclarationOnly": true, "sourceMap": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["node", "jest"], + "lib": [ + "DOM", + "DOM.Iterable", + "ES2022" + ], + "types": [ + "node", + "jest" + ], "incremental": true, "isolatedModules": true, "plugins": [ @@ -31,36 +38,72 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], - "@payloadcms/admin-bar": ["./packages/admin-bar/src"], - "@payloadcms/live-preview": ["./packages/live-preview/src"], - "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], - "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], - "@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"], - "@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"], - "@payloadcms/ui/rsc": ["./packages/ui/src/exports/rsc/index.ts"], - "@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"], - "@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"], - "@payloadcms/next/*": ["./packages/next/src/exports/*.ts"], + "@payload-config": [ + "./test/fields/config.ts" + ], + "@payloadcms/admin-bar": [ + "./packages/admin-bar/src" + ], + "@payloadcms/live-preview": [ + "./packages/live-preview/src" + ], + "@payloadcms/live-preview-react": [ + "./packages/live-preview-react/src/index.ts" + ], + "@payloadcms/live-preview-vue": [ + "./packages/live-preview-vue/src/index.ts" + ], + "@payloadcms/ui": [ + "./packages/ui/src/exports/client/index.ts" + ], + "@payloadcms/ui/shared": [ + "./packages/ui/src/exports/shared/index.ts" + ], + "@payloadcms/ui/rsc": [ + "./packages/ui/src/exports/rsc/index.ts" + ], + "@payloadcms/ui/scss": [ + "./packages/ui/src/scss.scss" + ], + "@payloadcms/ui/scss/app.scss": [ + "./packages/ui/src/scss/app.scss" + ], + "@payloadcms/next/*": [ + "./packages/next/src/exports/*.ts" + ], "@payloadcms/richtext-lexical/client": [ "./packages/richtext-lexical/src/exports/client/index.ts" ], - "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], - "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], + "@payloadcms/richtext-lexical/rsc": [ + "./packages/richtext-lexical/src/exports/server/rsc.ts" + ], + "@payloadcms/richtext-slate/rsc": [ + "./packages/richtext-slate/src/exports/server/rsc.ts" + ], "@payloadcms/richtext-slate/client": [ "./packages/richtext-slate/src/exports/client/index.ts" ], - "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"], - "@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"], - "@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"], - "@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"], + "@payloadcms/plugin-seo/client": [ + "./packages/plugin-seo/src/exports/client.ts" + ], + "@payloadcms/plugin-sentry/client": [ + "./packages/plugin-sentry/src/exports/client.ts" + ], + "@payloadcms/plugin-stripe/client": [ + "./packages/plugin-stripe/src/exports/client.ts" + ], + "@payloadcms/plugin-search/client": [ + "./packages/plugin-search/src/exports/client.ts" + ], "@payloadcms/plugin-form-builder/client": [ "./packages/plugin-form-builder/src/exports/client.ts" ], "@payloadcms/plugin-import-export/rsc": [ "./packages/plugin-import-export/src/exports/rsc.ts" ], - "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], + "@payloadcms/plugin-multi-tenant/rsc": [ + "./packages/plugin-multi-tenant/src/exports/rsc.ts" + ], "@payloadcms/plugin-multi-tenant/utilities": [ "./packages/plugin-multi-tenant/src/exports/utilities.ts" ], @@ -70,25 +113,42 @@ "@payloadcms/plugin-multi-tenant/client": [ "./packages/plugin-multi-tenant/src/exports/client.ts" ], - "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], + "@payloadcms/plugin-multi-tenant": [ + "./packages/plugin-multi-tenant/src/index.ts" + ], "@payloadcms/plugin-multi-tenant/translations/languages/all": [ "./packages/plugin-multi-tenant/src/translations/index.ts" ], "@payloadcms/plugin-multi-tenant/translations/languages/*": [ "./packages/plugin-multi-tenant/src/translations/languages/*.ts" ], - "@payloadcms/next": ["./packages/next/src/exports/*"], - "@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"], - "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], + "@payloadcms/next": [ + "./packages/next/src/exports/*" + ], + "@payloadcms/storage-azure/client": [ + "./packages/storage-azure/src/exports/client.ts" + ], + "@payloadcms/storage-s3/client": [ + "./packages/storage-s3/src/exports/client.ts" + ], "@payloadcms/storage-vercel-blob/client": [ "./packages/storage-vercel-blob/src/exports/client.ts" ], - "@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], + "@payloadcms/storage-gcs/client": [ + "./packages/storage-gcs/src/exports/client.ts" + ], "@payloadcms/storage-uploadthing/client": [ "./packages/storage-uploadthing/src/exports/client.ts" ] } }, - "include": ["${configDir}/src"], - "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] + "include": [ + "${configDir}/src" + ], + "exclude": [ + "${configDir}/dist", + "${configDir}/build", + "${configDir}/temp", + "**/*.spec.ts" + ] }