fix(ui): properly sync search params to user preferences (#13200)
Some search params within the list view do not properly sync to user preferences, and visa versa. For example, when selecting a query preset, the `?preset=123` param is injected into the URL and saved to preferences, but when reloading the page without the param, that preset is not reactivated as expected. ### Problem The reason this wasn't working before is that omitting this param would also reset prefs. It was designed this way in order to support client-side resets, e.g. clicking the query presets "x" button. This pattern would never work, however, because this means that every time the user navigates to the list view directly, their preference is cleared, as no param would exist in the query. Note: this is not an issue with _all_ params, as not all are handled in the same way. ### Solution The fix is to use empty values instead, e.g. `?preset=`. When the server receives this, it knows to clear the pref. If it doesn't exist at all, it knows to load from prefs. And if it has a value, it saves to prefs. On the client, we sanitize those empty values back out so they don't appear in the URL in the end. This PR also refactors much of the list query context and its respective provider to be significantly more predictable and easier to work with, namely: - The `ListQuery` type now fully aligns with what Payload APIs expect, e.g. `page` is a number, not a string - The provider now receives a single `query` prop which matches the underlying context 1:1 - Propagating the query from the server to the URL is significantly more predictable - Any new props that may be supported in the future will automatically work - No more reconciling `columns` and `listPreferences.columns`, its just `query.columns` --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210827129744922
This commit is contained in:
@@ -2,13 +2,11 @@ import type {
|
|||||||
AdminViewServerProps,
|
AdminViewServerProps,
|
||||||
CollectionPreferences,
|
CollectionPreferences,
|
||||||
ColumnPreference,
|
ColumnPreference,
|
||||||
DefaultDocumentIDType,
|
|
||||||
ListQuery,
|
ListQuery,
|
||||||
ListViewClientProps,
|
ListViewClientProps,
|
||||||
ListViewServerPropsOnly,
|
ListViewServerPropsOnly,
|
||||||
QueryPreset,
|
QueryPreset,
|
||||||
SanitizedCollectionPermission,
|
SanitizedCollectionPermission,
|
||||||
Where,
|
|
||||||
} from 'payload'
|
} from 'payload'
|
||||||
|
|
||||||
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
|
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
|
||||||
@@ -20,6 +18,7 @@ import {
|
|||||||
isNumber,
|
isNumber,
|
||||||
mergeListSearchAndWhere,
|
mergeListSearchAndWhere,
|
||||||
transformColumnsToPreferences,
|
transformColumnsToPreferences,
|
||||||
|
transformColumnsToSearchParams,
|
||||||
} from 'payload/shared'
|
} from 'payload/shared'
|
||||||
import React, { Fragment } from 'react'
|
import React, { Fragment } from 'react'
|
||||||
|
|
||||||
@@ -87,28 +86,33 @@ export const renderListView = async (
|
|||||||
throw new Error('not-found')
|
throw new Error('not-found')
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = queryFromArgs || queryFromReq
|
const query: ListQuery = queryFromArgs || queryFromReq
|
||||||
|
|
||||||
const columns: ColumnPreference[] = transformColumnsToPreferences(
|
const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns)
|
||||||
query?.columns as ColumnPreference[] | string,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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<CollectionPreferences>({
|
const collectionPreferences = await upsertPreferences<CollectionPreferences>({
|
||||||
key: `collection-${collectionSlug}`,
|
key: `collection-${collectionSlug}`,
|
||||||
req,
|
req,
|
||||||
value: {
|
value: {
|
||||||
columns,
|
columns: columnsFromQuery,
|
||||||
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
|
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
|
||||||
preset: (query?.preset as DefaultDocumentIDType) || null,
|
preset: query?.preset,
|
||||||
sort: query?.sort as string,
|
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 {
|
const {
|
||||||
routes: { admin: adminRoute },
|
routes: { admin: adminRoute },
|
||||||
} = config
|
} = config
|
||||||
@@ -118,35 +122,27 @@ export const renderListView = async (
|
|||||||
throw new Error('not-found')
|
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') {
|
if (typeof collectionConfig.admin?.baseListFilter === 'function') {
|
||||||
const baseListFilter = await collectionConfig.admin.baseListFilter({
|
const baseListFilter = await collectionConfig.admin.baseListFilter({
|
||||||
limit,
|
limit: query.limit,
|
||||||
page,
|
page: query.page,
|
||||||
req,
|
req,
|
||||||
sort,
|
sort: query.sort,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (baseListFilter) {
|
if (baseListFilter) {
|
||||||
where = {
|
query.where = {
|
||||||
and: [where, baseListFilter].filter(Boolean),
|
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 queryPreset: QueryPreset | undefined
|
||||||
let queryPresetPermissions: SanitizedCollectionPermission | undefined
|
let queryPresetPermissions: SanitizedCollectionPermission | undefined
|
||||||
|
|
||||||
@@ -179,14 +175,14 @@ export const renderListView = async (
|
|||||||
draft: true,
|
draft: true,
|
||||||
fallbackLocale: false,
|
fallbackLocale: false,
|
||||||
includeLockStatus: true,
|
includeLockStatus: true,
|
||||||
limit,
|
limit: query.limit,
|
||||||
locale,
|
locale,
|
||||||
overrideAccess: false,
|
overrideAccess: false,
|
||||||
page,
|
page: query.page,
|
||||||
req,
|
req,
|
||||||
sort,
|
sort: query.sort,
|
||||||
user,
|
user,
|
||||||
where: where || {},
|
where: whereWithMergedSearch,
|
||||||
})
|
})
|
||||||
|
|
||||||
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
|
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
|
||||||
@@ -194,8 +190,7 @@ export const renderListView = async (
|
|||||||
const { columnState, Table } = renderTable({
|
const { columnState, Table } = renderTable({
|
||||||
clientCollectionConfig,
|
clientCollectionConfig,
|
||||||
collectionConfig,
|
collectionConfig,
|
||||||
columnPreferences: collectionPreferences?.columns,
|
columns: collectionPreferences?.columns,
|
||||||
columns,
|
|
||||||
customCellProps,
|
customCellProps,
|
||||||
docs: data.docs,
|
docs: data.docs,
|
||||||
drawerSlug,
|
drawerSlug,
|
||||||
@@ -232,7 +227,7 @@ export const renderListView = async (
|
|||||||
collectionConfig,
|
collectionConfig,
|
||||||
data,
|
data,
|
||||||
i18n,
|
i18n,
|
||||||
limit,
|
limit: query.limit,
|
||||||
listPreferences: collectionPreferences,
|
listPreferences: collectionPreferences,
|
||||||
listSearchableFields: collectionConfig.admin.listSearchableFields,
|
listSearchableFields: collectionConfig.admin.listSearchableFields,
|
||||||
locale: fullLocale,
|
locale: fullLocale,
|
||||||
@@ -258,19 +253,19 @@ export const renderListView = async (
|
|||||||
|
|
||||||
const isInDrawer = Boolean(drawerSlug)
|
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 {
|
return {
|
||||||
List: (
|
List: (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<HydrateAuthProvider permissions={permissions} />
|
<HydrateAuthProvider permissions={permissions} />
|
||||||
<ListQueryProvider
|
<ListQueryProvider
|
||||||
collectionSlug={collectionSlug}
|
collectionSlug={collectionSlug}
|
||||||
columns={transformColumnsToPreferences(columnState)}
|
|
||||||
data={data}
|
data={data}
|
||||||
defaultLimit={limit}
|
|
||||||
defaultSort={sort}
|
|
||||||
listPreferences={collectionPreferences}
|
|
||||||
modifySearchParams={!isInDrawer}
|
modifySearchParams={!isInDrawer}
|
||||||
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
|
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
|
||||||
|
query={query}
|
||||||
>
|
>
|
||||||
{RenderServerComponent({
|
{RenderServerComponent({
|
||||||
clientProps: {
|
clientProps: {
|
||||||
|
|||||||
@@ -148,10 +148,12 @@ export async function VersionsView(props: DocumentViewServerProps) {
|
|||||||
<GutterComponent className={`${baseClass}__wrap`}>
|
<GutterComponent className={`${baseClass}__wrap`}>
|
||||||
<ListQueryProvider
|
<ListQueryProvider
|
||||||
data={versionsData}
|
data={versionsData}
|
||||||
defaultLimit={limitToUse}
|
|
||||||
defaultSort={sort as string}
|
|
||||||
modifySearchParams
|
modifySearchParams
|
||||||
orderableFieldName={collectionConfig?.orderable === true ? '_order' : undefined}
|
orderableFieldName={collectionConfig?.orderable === true ? '_order' : undefined}
|
||||||
|
query={{
|
||||||
|
limit: limitToUse,
|
||||||
|
sort: sort as string,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<VersionsViewClient
|
<VersionsViewClient
|
||||||
baseClass={baseClass}
|
baseClass={baseClass}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export type ListQuery = {
|
|||||||
* Use `transformColumnsToPreferences` and `transformColumnsToSearchParams` to convert it back and forth
|
* Use `transformColumnsToPreferences` and `transformColumnsToSearchParams` to convert it back and forth
|
||||||
*/
|
*/
|
||||||
columns?: ColumnsFromURL
|
columns?: ColumnsFromURL
|
||||||
limit?: string
|
limit?: number
|
||||||
page?: string
|
page?: number
|
||||||
preset?: number | string
|
preset?: number | string
|
||||||
/*
|
/*
|
||||||
When provided, is automatically injected into the `where` object
|
When provided, is automatically injected into the `where` object
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type ColumnsFromURL = string[]
|
|||||||
* This means that when handling columns, they need to be consistently transformed back and forth
|
* This means that when handling columns, they need to be consistently transformed back and forth
|
||||||
*/
|
*/
|
||||||
export const transformColumnsToPreferences = (
|
export const transformColumnsToPreferences = (
|
||||||
columns: Column[] | ColumnPreference[] | ColumnsFromURL | string,
|
columns: Column[] | ColumnPreference[] | ColumnsFromURL | string | undefined,
|
||||||
): ColumnPreference[] | undefined => {
|
): ColumnPreference[] | undefined => {
|
||||||
let columnsToTransform = columns
|
let columnsToTransform = columns
|
||||||
|
|
||||||
@@ -44,5 +44,5 @@ export const transformColumnsToPreferences = (
|
|||||||
export const transformColumnsToSearchParams = (
|
export const transformColumnsToSearchParams = (
|
||||||
columns: Column[] | ColumnPreference[],
|
columns: Column[] | ColumnPreference[],
|
||||||
): ColumnsFromURL => {
|
): ColumnsFromURL => {
|
||||||
return columns.map((col) => (col.active ? col.accessor : `-${col.accessor}`))
|
return columns?.map((col) => (col.active ? col.accessor : `-${col.accessor}`))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import { validOperatorSet } from '../types/constants.js'
|
|||||||
export const validateWhereQuery = (whereQuery: Where): whereQuery is Where => {
|
export const validateWhereQuery = (whereQuery: Where): whereQuery is Where => {
|
||||||
if (
|
if (
|
||||||
whereQuery?.or &&
|
whereQuery?.or &&
|
||||||
whereQuery?.or?.length > 0 &&
|
(whereQuery?.or?.length === 0 ||
|
||||||
whereQuery?.or?.[0]?.and &&
|
(whereQuery?.or?.length > 0 &&
|
||||||
whereQuery?.or?.[0]?.and?.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,
|
// At this point we know that the whereQuery has 'or' and 'and' fields,
|
||||||
// now let's check the structure and content of these fields.
|
// now let's check the structure and content of these fields.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CollectionSlug, QueryPreset, SanitizedCollectionPermission } from
|
|||||||
import { useModal } from '@faceless-ui/modal'
|
import { useModal } from '@faceless-ui/modal'
|
||||||
import { getTranslation } from '@payloadcms/translations'
|
import { getTranslation } from '@payloadcms/translations'
|
||||||
import { transformColumnsToPreferences, transformColumnsToSearchParams } from 'payload/shared'
|
import { transformColumnsToPreferences, transformColumnsToSearchParams } from 'payload/shared'
|
||||||
import React, { Fragment, useCallback, useMemo } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { useConfig } from '../../providers/Config/index.js'
|
import { useConfig } from '../../providers/Config/index.js'
|
||||||
@@ -103,9 +103,9 @@ export const useQueryPresets = ({
|
|||||||
const resetQueryPreset = useCallback(async () => {
|
const resetQueryPreset = useCallback(async () => {
|
||||||
await refineListData(
|
await refineListData(
|
||||||
{
|
{
|
||||||
columns: undefined,
|
columns: [],
|
||||||
preset: undefined,
|
preset: '',
|
||||||
where: undefined,
|
where: {},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
const renderTable = useCallback(
|
const renderTable = useCallback(
|
||||||
async (docs?: PaginatedDocs['docs']) => {
|
async (docs?: PaginatedDocs['docs']) => {
|
||||||
const newQuery: ListQuery = {
|
const newQuery: ListQuery = {
|
||||||
limit: String(field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit),
|
limit: field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit,
|
||||||
sort: field.defaultSort || collectionConfig?.defaultSort,
|
sort: field.defaultSort || collectionConfig?.defaultSort,
|
||||||
...(query || {}),
|
...(query || {}),
|
||||||
where: { ...(query?.where || {}) },
|
where: { ...(query?.where || {}) },
|
||||||
@@ -240,6 +240,15 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isDrawerOpen])
|
}, [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 (
|
return (
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
<div className={`${baseClass}__header`}>
|
<div className={`${baseClass}__header`}>
|
||||||
@@ -306,12 +315,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
{data?.docs && data.docs.length > 0 && (
|
{data?.docs && data.docs.length > 0 && (
|
||||||
<RelationshipProvider>
|
<RelationshipProvider>
|
||||||
<ListQueryProvider
|
<ListQueryProvider
|
||||||
columns={transformColumnsToPreferences(columnState)}
|
|
||||||
data={data}
|
data={data}
|
||||||
defaultLimit={
|
|
||||||
field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit
|
|
||||||
}
|
|
||||||
defaultSort={field.defaultSort ?? collectionConfig?.defaultSort}
|
|
||||||
modifySearchParams={false}
|
modifySearchParams={false}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
orderableFieldName={
|
orderableFieldName={
|
||||||
@@ -319,6 +323,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
? undefined
|
? undefined
|
||||||
: `_${field.collection}_${fieldPath.replaceAll('.', '_')}_order`
|
: `_${field.collection}_${fieldPath.replaceAll('.', '_')}_order`
|
||||||
}
|
}
|
||||||
|
query={memoizedQuery}
|
||||||
>
|
>
|
||||||
<TableColumnsProvider
|
<TableColumnsProvider
|
||||||
collectionSlug={isPolymorphic ? relationTo[0] : relationTo}
|
collectionSlug={isPolymorphic ? relationTo[0] : relationTo}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation.js'
|
import { useRouter, useSearchParams } from 'next/navigation.js'
|
||||||
import { type ListQuery, type Where } from 'payload'
|
import { type ListQuery, type Where } from 'payload'
|
||||||
import { isNumber, transformColumnsToSearchParams } from 'payload/shared'
|
|
||||||
import * as qs from 'qs-esm'
|
import * as qs from 'qs-esm'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
@@ -12,23 +11,22 @@ import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
|||||||
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
|
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
|
||||||
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
|
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
|
||||||
import { ListQueryContext, ListQueryModifiedContext } from './context.js'
|
import { ListQueryContext, ListQueryModifiedContext } from './context.js'
|
||||||
|
import { mergeQuery } from './mergeQuery.js'
|
||||||
|
import { sanitizeQuery } from './sanitizeQuery.js'
|
||||||
|
|
||||||
export { useListQuery } from './context.js'
|
export { useListQuery } from './context.js'
|
||||||
|
|
||||||
export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
||||||
children,
|
children,
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
columns,
|
|
||||||
data,
|
data,
|
||||||
defaultLimit,
|
|
||||||
defaultSort,
|
|
||||||
listPreferences,
|
|
||||||
modifySearchParams,
|
modifySearchParams,
|
||||||
onQueryChange: onQueryChangeFromProps,
|
onQueryChange: onQueryChangeFromProps,
|
||||||
orderableFieldName,
|
orderableFieldName,
|
||||||
|
query: queryFromProps,
|
||||||
}) => {
|
}) => {
|
||||||
// TODO: Investigate if this is still needed
|
// TODO: Investigate if this is still needed
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
|
||||||
'use no memo'
|
'use no memo'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const rawSearchParams = useSearchParams()
|
const rawSearchParams = useSearchParams()
|
||||||
@@ -36,7 +34,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
const [modified, setModified] = useState(false)
|
const [modified, setModified] = useState(false)
|
||||||
|
|
||||||
const searchParams = useMemo<ListQuery>(
|
const searchParams = useMemo<ListQuery>(
|
||||||
() => parseSearchParams(rawSearchParams),
|
() => sanitizeQuery(parseSearchParams(rawSearchParams)),
|
||||||
[rawSearchParams],
|
[rawSearchParams],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,37 +49,12 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
return searchParams
|
return searchParams
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
limit: String(defaultLimit),
|
limit: queryFromProps.limit,
|
||||||
sort: defaultSort,
|
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(
|
const refineListData = useCallback(
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
async (incomingQuery: ListQuery, modified?: boolean) => {
|
async (incomingQuery: ListQuery, modified?: boolean) => {
|
||||||
@@ -91,12 +64,23 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
setModified(true)
|
setModified(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const newQuery = mergeQuery(incomingQuery)
|
const newQuery = mergeQuery(currentQuery, incomingQuery, {
|
||||||
|
defaults: {
|
||||||
|
limit: queryFromProps.limit,
|
||||||
|
sort: queryFromProps.sort,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (modifySearchParams) {
|
if (modifySearchParams) {
|
||||||
startRouteTransition(() =>
|
startRouteTransition(() =>
|
||||||
router.replace(
|
router.replace(
|
||||||
`${qs.stringify({ ...newQuery, columns: JSON.stringify(newQuery.columns) }, { addQueryPrefix: true })}`,
|
`${qs.stringify(
|
||||||
|
{
|
||||||
|
...newQuery,
|
||||||
|
columns: JSON.stringify(newQuery.columns),
|
||||||
|
},
|
||||||
|
{ addQueryPrefix: true },
|
||||||
|
)}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
@@ -110,7 +94,9 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
setCurrentQuery(newQuery)
|
setCurrentQuery(newQuery)
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
mergeQuery,
|
currentQuery,
|
||||||
|
queryFromProps.limit,
|
||||||
|
queryFromProps.sort,
|
||||||
modifySearchParams,
|
modifySearchParams,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
onQueryChangeFromProps,
|
onQueryChangeFromProps,
|
||||||
@@ -121,14 +107,14 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
async (arg: number) => {
|
async (arg: number) => {
|
||||||
await refineListData({ page: String(arg) })
|
await refineListData({ page: arg })
|
||||||
},
|
},
|
||||||
[refineListData],
|
[refineListData],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePerPageChange = React.useCallback(
|
const handlePerPageChange = React.useCallback(
|
||||||
async (arg: number) => {
|
async (arg: number) => {
|
||||||
await refineListData({ limit: String(arg), page: '1' })
|
await refineListData({ limit: arg, page: 1 })
|
||||||
},
|
},
|
||||||
[refineListData],
|
[refineListData],
|
||||||
)
|
)
|
||||||
@@ -155,47 +141,26 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
[refineListData],
|
[refineListData],
|
||||||
)
|
)
|
||||||
|
|
||||||
const syncQuery = useEffectEvent(() => {
|
const mergeQueryFromPropsAndSyncToURL = useEffectEvent(() => {
|
||||||
let shouldUpdateQueryString = false
|
const newQuery = sanitizeQuery({ ...(currentQuery || {}), ...(queryFromProps || {}) })
|
||||||
const newQuery = { ...(currentQuery || {}) }
|
|
||||||
|
|
||||||
// Allow the URL to override the default limit
|
const search = `?${qs.stringify({ ...newQuery, columns: JSON.stringify(newQuery.columns) })}`
|
||||||
if (isNumber(defaultLimit) && !('limit' in currentQuery)) {
|
|
||||||
newQuery.limit = String(defaultLimit)
|
|
||||||
shouldUpdateQueryString = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow the URL to override the default sort
|
if (window.location.search !== search) {
|
||||||
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) {
|
|
||||||
setCurrentQuery(newQuery)
|
setCurrentQuery(newQuery)
|
||||||
// Do not use router.replace here to avoid re-rendering on initial load
|
|
||||||
window.history.replaceState(
|
// Important: do not use router.replace here to avoid re-rendering on initial load
|
||||||
null,
|
window.history.replaceState(null, '', search)
|
||||||
'',
|
|
||||||
`?${qs.stringify({ ...newQuery, columns: JSON.stringify(newQuery.columns) })}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// If `defaultLimit` or `defaultSort` are updated externally, update the query
|
// If `query` is updated externally, update the local state
|
||||||
// I.e. when HMR runs, these properties may be different
|
// E.g. when HMR runs, these properties may be different
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modifySearchParams) {
|
if (modifySearchParams) {
|
||||||
syncQuery()
|
mergeQueryFromPropsAndSyncToURL()
|
||||||
}
|
}
|
||||||
}, [defaultSort, defaultLimit, modifySearchParams, columns])
|
}, [modifySearchParams, queryFromProps])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListQueryContext
|
<ListQueryContext
|
||||||
|
|||||||
36
packages/ui/src/providers/ListQuery/mergeQuery.ts
Normal file
36
packages/ui/src/providers/ListQuery/mergeQuery.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ListQuery } from 'payload'
|
||||||
|
|
||||||
|
export const mergeQuery = (
|
||||||
|
currentQuery: ListQuery,
|
||||||
|
newQuery: ListQuery,
|
||||||
|
options?: {
|
||||||
|
defaults?: 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,
|
||||||
|
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
|
||||||
|
}
|
||||||
38
packages/ui/src/providers/ListQuery/sanitizeQuery.ts
Normal file
38
packages/ui/src/providers/ListQuery/sanitizeQuery.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
ClientCollectionConfig,
|
ClientCollectionConfig,
|
||||||
CollectionPreferences,
|
|
||||||
ColumnPreference,
|
ColumnPreference,
|
||||||
ListQuery,
|
ListQuery,
|
||||||
PaginatedDocs,
|
PaginatedDocs,
|
||||||
@@ -21,11 +20,7 @@ export type OnListQueryChange = (query: ListQuery) => void
|
|||||||
export type ListQueryProps = {
|
export type ListQueryProps = {
|
||||||
readonly children: React.ReactNode
|
readonly children: React.ReactNode
|
||||||
readonly collectionSlug?: ClientCollectionConfig['slug']
|
readonly collectionSlug?: ClientCollectionConfig['slug']
|
||||||
readonly columns?: ColumnPreference[]
|
|
||||||
readonly data: PaginatedDocs
|
readonly data: PaginatedDocs
|
||||||
readonly defaultLimit?: number
|
|
||||||
readonly defaultSort?: Sort
|
|
||||||
readonly listPreferences?: CollectionPreferences
|
|
||||||
readonly modifySearchParams?: boolean
|
readonly modifySearchParams?: boolean
|
||||||
readonly onQueryChange?: OnListQueryChange
|
readonly onQueryChange?: OnListQueryChange
|
||||||
readonly orderableFieldName?: string
|
readonly orderableFieldName?: string
|
||||||
@@ -33,6 +28,7 @@ export type ListQueryProps = {
|
|||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
readonly preferenceKey?: string
|
readonly preferenceKey?: string
|
||||||
|
query?: ListQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IListQueryContext = {
|
export type IListQueryContext = {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import { sortFieldMap } from './sortFieldMap.js'
|
|||||||
export type BuildColumnStateArgs = {
|
export type BuildColumnStateArgs = {
|
||||||
beforeRows?: Column[]
|
beforeRows?: Column[]
|
||||||
clientFields: ClientField[]
|
clientFields: ClientField[]
|
||||||
columnPreferences: CollectionPreferences['columns']
|
|
||||||
columns?: CollectionPreferences['columns']
|
columns?: CollectionPreferences['columns']
|
||||||
customCellProps: DefaultCellComponentProps['customCellProps']
|
customCellProps: DefaultCellComponentProps['customCellProps']
|
||||||
enableLinkedCell?: boolean
|
enableLinkedCell?: boolean
|
||||||
@@ -70,7 +69,6 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
|
|||||||
beforeRows,
|
beforeRows,
|
||||||
clientFields,
|
clientFields,
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
columnPreferences,
|
|
||||||
columns,
|
columns,
|
||||||
customCellProps,
|
customCellProps,
|
||||||
dataType,
|
dataType,
|
||||||
@@ -99,7 +97,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
|
|||||||
|
|
||||||
// place the `ID` field first, if it exists
|
// place the `ID` field first, if it exists
|
||||||
// do the same for the `useAsTitle` field with precedence over the `ID` field
|
// 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))
|
const idFieldIndex = sortedFieldMap?.findIndex((field) => fieldIsID(field))
|
||||||
|
|
||||||
if (idFieldIndex > -1) {
|
if (idFieldIndex > -1) {
|
||||||
@@ -116,10 +114,10 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
|
|||||||
sortedFieldMap.unshift(useAsTitleField)
|
sortedFieldMap.unshift(useAsTitleField)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortTo = columnPreferences || columns
|
const sortTo = columns
|
||||||
|
|
||||||
if (sortTo) {
|
if (sortTo) {
|
||||||
// sort the fields to the order of `defaultColumns` or `columnPreferences`
|
// sort the fields to the order of `defaultColumns` or `columns`
|
||||||
sortedFieldMap = sortFieldMap<ClientField>(sortedFieldMap, sortTo)
|
sortedFieldMap = sortFieldMap<ClientField>(sortedFieldMap, sortTo)
|
||||||
_sortedFieldMap = sortFieldMap<Field>(_sortedFieldMap, sortTo) // TODO: think of a way to avoid this additional sort
|
_sortedFieldMap = sortFieldMap<Field>(_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
|
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,
|
(preference) => clientField && 'name' in clientField && preference.accessor === accessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isActive = isColumnActive({
|
const isActive = isColumnActive({
|
||||||
accessor,
|
accessor,
|
||||||
activeColumnsIndices,
|
activeColumnsIndices,
|
||||||
columnPreference,
|
column: columnPref,
|
||||||
columns,
|
columns,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ import type { ColumnPreference } from 'payload'
|
|||||||
export function isColumnActive({
|
export function isColumnActive({
|
||||||
accessor,
|
accessor,
|
||||||
activeColumnsIndices,
|
activeColumnsIndices,
|
||||||
columnPreference,
|
column,
|
||||||
columns,
|
columns,
|
||||||
}: {
|
}: {
|
||||||
accessor: string
|
accessor: string
|
||||||
activeColumnsIndices: number[]
|
activeColumnsIndices: number[]
|
||||||
columnPreference: ColumnPreference
|
column: ColumnPreference
|
||||||
columns: ColumnPreference[]
|
columns: ColumnPreference[]
|
||||||
}) {
|
}) {
|
||||||
if (columnPreference) {
|
if (column) {
|
||||||
return columnPreference.active
|
return column.active
|
||||||
} else if (columns && Array.isArray(columns) && columns.length > 0) {
|
} 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) {
|
} else if (activeColumnsIndices.length < 4) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ const buildTableState = async (
|
|||||||
collection: collectionSlug,
|
collection: collectionSlug,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
draft: true,
|
draft: true,
|
||||||
limit: query?.limit ? parseInt(query.limit, 10) : undefined,
|
limit: query?.limit,
|
||||||
locale: req.locale,
|
locale: req.locale,
|
||||||
overrideAccess: false,
|
overrideAccess: false,
|
||||||
page: query?.page ? parseInt(query.page, 10) : undefined,
|
page: query?.page,
|
||||||
sort: query?.sort,
|
sort: query?.sort,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
where: query?.where,
|
where: query?.where,
|
||||||
@@ -232,7 +232,6 @@ const buildTableState = async (
|
|||||||
clientConfig,
|
clientConfig,
|
||||||
collectionConfig,
|
collectionConfig,
|
||||||
collections: Array.isArray(collectionSlug) ? collectionSlug : undefined,
|
collections: Array.isArray(collectionSlug) ? collectionSlug : undefined,
|
||||||
columnPreferences: Array.isArray(collectionSlug) ? collectionPreferences?.columns : undefined, // TODO, might not be neededcolumns,
|
|
||||||
columns,
|
columns,
|
||||||
docs,
|
docs,
|
||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export const renderTable = ({
|
|||||||
clientConfig,
|
clientConfig,
|
||||||
collectionConfig,
|
collectionConfig,
|
||||||
collections,
|
collections,
|
||||||
columnPreferences,
|
|
||||||
columns: columnsFromArgs,
|
columns: columnsFromArgs,
|
||||||
customCellProps,
|
customCellProps,
|
||||||
docs,
|
docs,
|
||||||
@@ -80,7 +79,6 @@ export const renderTable = ({
|
|||||||
clientConfig?: ClientConfig
|
clientConfig?: ClientConfig
|
||||||
collectionConfig?: SanitizedCollectionConfig
|
collectionConfig?: SanitizedCollectionConfig
|
||||||
collections?: string[]
|
collections?: string[]
|
||||||
columnPreferences: CollectionPreferences['columns']
|
|
||||||
columns?: CollectionPreferences['columns']
|
columns?: CollectionPreferences['columns']
|
||||||
customCellProps?: Record<string, unknown>
|
customCellProps?: Record<string, unknown>
|
||||||
docs: PaginatedDocs['docs']
|
docs: PaginatedDocs['docs']
|
||||||
@@ -154,7 +152,6 @@ export const renderTable = ({
|
|||||||
const sharedArgs: Pick<
|
const sharedArgs: Pick<
|
||||||
BuildColumnStateArgs,
|
BuildColumnStateArgs,
|
||||||
| 'clientFields'
|
| 'clientFields'
|
||||||
| 'columnPreferences'
|
|
||||||
| 'columns'
|
| 'columns'
|
||||||
| 'customCellProps'
|
| 'customCellProps'
|
||||||
| 'enableRowSelections'
|
| 'enableRowSelections'
|
||||||
@@ -164,7 +161,6 @@ export const renderTable = ({
|
|||||||
| 'useAsTitle'
|
| 'useAsTitle'
|
||||||
> = {
|
> = {
|
||||||
clientFields,
|
clientFields,
|
||||||
columnPreferences,
|
|
||||||
columns,
|
columns,
|
||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
i18n,
|
i18n,
|
||||||
|
|||||||
@@ -5,13 +5,26 @@ import { cache } from 'react'
|
|||||||
|
|
||||||
import { removeUndefined } from './removeUndefined.js'
|
import { removeUndefined } from './removeUndefined.js'
|
||||||
|
|
||||||
|
type PreferenceDoc<T> = {
|
||||||
|
id: DefaultDocumentIDType | undefined
|
||||||
|
value?: T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultMerge = <T>(existingValue: T, incomingValue: T | undefined) => T
|
||||||
|
|
||||||
|
const defaultMerge: DefaultMerge = <T>(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(
|
export const getPreferences = cache(
|
||||||
async <T>(
|
async <T>(
|
||||||
key: string,
|
key: string,
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
userID: DefaultDocumentIDType,
|
userID: DefaultDocumentIDType,
|
||||||
userSlug: string,
|
userSlug: string,
|
||||||
): Promise<{ id: DefaultDocumentIDType; value: T }> => {
|
): Promise<PreferenceDoc<T>> => {
|
||||||
const result = (await payload
|
const result = (await payload
|
||||||
.find({
|
.find({
|
||||||
collection: 'payload-preferences',
|
collection: 'payload-preferences',
|
||||||
@@ -58,21 +71,14 @@ export const upsertPreferences = async <T extends Record<string, unknown> | stri
|
|||||||
req,
|
req,
|
||||||
value: incomingValue,
|
value: incomingValue,
|
||||||
}: {
|
}: {
|
||||||
|
customMerge?: (existingValue: T, incomingValue: T, defaultMerge: DefaultMerge) => T
|
||||||
key: string
|
key: string
|
||||||
req: PayloadRequest
|
req: PayloadRequest
|
||||||
} & (
|
value: T
|
||||||
| {
|
}): Promise<T> => {
|
||||||
customMerge: (existingValue: T) => T
|
const existingPrefs: PreferenceDoc<T> = req.user
|
||||||
value?: never
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
customMerge?: never
|
|
||||||
value: T
|
|
||||||
}
|
|
||||||
)): Promise<T> => {
|
|
||||||
const existingPrefs: { id?: DefaultDocumentIDType; value?: T } = req.user
|
|
||||||
? await getPreferences<T>(key, req.payload, req.user.id, req.user.collection)
|
? await getPreferences<T>(key, req.payload, req.user.id, req.user.collection)
|
||||||
: {}
|
: ({} as PreferenceDoc<T>)
|
||||||
|
|
||||||
let newPrefs = existingPrefs?.value
|
let newPrefs = existingPrefs?.value
|
||||||
|
|
||||||
@@ -95,15 +101,12 @@ export const upsertPreferences = async <T extends Record<string, unknown> | stri
|
|||||||
let mergedPrefs: T
|
let mergedPrefs: T
|
||||||
|
|
||||||
if (typeof customMerge === 'function') {
|
if (typeof customMerge === 'function') {
|
||||||
mergedPrefs = customMerge(existingPrefs.value)
|
mergedPrefs = customMerge(existingPrefs.value, incomingValue, defaultMerge)
|
||||||
} else {
|
} else {
|
||||||
// Strings are valid JSON, i.e. `locale` saved as a string to the locale preferences
|
// Strings are valid JSON, i.e. `locale` saved as a string to the locale preferences
|
||||||
mergedPrefs =
|
mergedPrefs =
|
||||||
typeof incomingValue === 'object'
|
typeof incomingValue === 'object'
|
||||||
? ({
|
? defaultMerge<T>(existingPrefs.value, incomingValue)
|
||||||
...(typeof existingPrefs.value === 'object' ? existingPrefs?.value : {}), // Shallow merge existing prefs to acquire any missing keys from incoming value
|
|
||||||
...removeUndefined(incomingValue || {}),
|
|
||||||
} as T)
|
|
||||||
: incomingValue
|
: incomingValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const testEslintConfig = [
|
|||||||
'expectNoResultsAndCreateFolderButton',
|
'expectNoResultsAndCreateFolderButton',
|
||||||
'createFolder',
|
'createFolder',
|
||||||
'createFolderFromDoc',
|
'createFolderFromDoc',
|
||||||
|
'assertURLParams',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -549,6 +549,14 @@ export interface BlockField {
|
|||||||
}
|
}
|
||||||
)[]
|
)[]
|
||||||
| null;
|
| null;
|
||||||
|
readOnly?:
|
||||||
|
| {
|
||||||
|
title?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
blockName?: string | null;
|
||||||
|
blockType: 'readOnlyBlock';
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -2222,6 +2230,17 @@ export interface BlockFieldsSelect<T extends boolean = true> {
|
|||||||
blockName?: T;
|
blockName?: T;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
readOnly?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
readOnlyBlock?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
title?: T;
|
||||||
|
id?: T;
|
||||||
|
blockName?: T;
|
||||||
|
};
|
||||||
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test'
|
|||||||
import { devUser } from 'credentials.js'
|
import { devUser } from 'credentials.js'
|
||||||
import { openListColumns } from 'helpers/e2e/openListColumns.js'
|
import { openListColumns } from 'helpers/e2e/openListColumns.js'
|
||||||
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
|
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
|
||||||
|
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@@ -152,23 +153,38 @@ describe('Query Presets', () => {
|
|||||||
|
|
||||||
test('should select preset and apply filters', async () => {
|
test('should select preset and apply filters', async () => {
|
||||||
await page.goto(pagesUrl.list)
|
await page.goto(pagesUrl.list)
|
||||||
|
|
||||||
await selectPreset({ page, presetTitle: seededData.everyone.title })
|
await selectPreset({ page, presetTitle: seededData.everyone.title })
|
||||||
|
|
||||||
await assertURLParams({
|
await assertURLParams({
|
||||||
page,
|
page,
|
||||||
columns: seededData.everyone.columns,
|
columns: seededData.everyone.columns,
|
||||||
where: seededData.everyone.where,
|
preset: everyoneID,
|
||||||
presetID: everyoneID,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(true).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should clear selected preset and reset filters', async () => {
|
test('should clear selected preset and reset filters', async () => {
|
||||||
await page.goto(pagesUrl.list)
|
await page.goto(pagesUrl.list)
|
||||||
|
|
||||||
await selectPreset({ page, presetTitle: seededData.everyone.title })
|
await selectPreset({ page, presetTitle: seededData.everyone.title })
|
||||||
|
|
||||||
await clearSelectedPreset({ page })
|
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 () => {
|
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 () => {
|
test('should save last used preset to preferences and load on initial render', async () => {
|
||||||
await page.goto(pagesUrl.list)
|
await page.goto(pagesUrl.list)
|
||||||
|
|
||||||
await selectPreset({ page, presetTitle: seededData.everyone.title })
|
await selectPreset({ page, presetTitle: seededData.everyone.title })
|
||||||
|
|
||||||
await page.reload()
|
await page.goto(pagesUrl.list)
|
||||||
|
|
||||||
await assertURLParams({
|
await assertURLParams({
|
||||||
page,
|
page,
|
||||||
columns: seededData.everyone.columns,
|
columns: seededData.everyone.columns,
|
||||||
where: seededData.everyone.where,
|
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 () => {
|
test('should only show "edit" and "delete" controls when there is an active preset', async () => {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ export async function assertURLParams({
|
|||||||
page,
|
page,
|
||||||
columns,
|
columns,
|
||||||
where,
|
where,
|
||||||
presetID,
|
preset,
|
||||||
}: {
|
}: {
|
||||||
columns?: ColumnPreference[]
|
columns?: ColumnPreference[]
|
||||||
page: Page
|
page: Page
|
||||||
presetID?: string | undefined
|
preset?: string | undefined
|
||||||
where: Where
|
where?: Where
|
||||||
}) {
|
}) {
|
||||||
if (where) {
|
if (where) {
|
||||||
// TODO: can't get columns to encode correctly
|
// TODO: can't get columns to encode correctly
|
||||||
@@ -32,8 +32,8 @@ export async function assertURLParams({
|
|||||||
await page.waitForURL(columnsRegex)
|
await page.waitForURL(columnsRegex)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (presetID) {
|
if (preset) {
|
||||||
const presetRegex = new RegExp(`preset=${presetID}`)
|
const presetRegex = new RegExp(`preset=${preset}`)
|
||||||
await page.waitForURL(presetRegex)
|
await page.waitForURL(presetRegex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,13 @@ export interface User {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
|
sessions?:
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -301,6 +308,13 @@ export interface UsersSelect<T extends boolean = true> {
|
|||||||
hash?: T;
|
hash?: T;
|
||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
|||||||
@@ -21,8 +21,15 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
"lib": [
|
||||||
"types": ["node", "jest"],
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"ES2022"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"node",
|
||||||
|
"jest"
|
||||||
|
],
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
@@ -31,36 +38,72 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@payload-config": ["./test/_community/config.ts"],
|
"@payload-config": [
|
||||||
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
|
"./test/fields/config.ts"
|
||||||
"@payloadcms/live-preview": ["./packages/live-preview/src"],
|
],
|
||||||
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
"@payloadcms/admin-bar": [
|
||||||
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
|
"./packages/admin-bar/src"
|
||||||
"@payloadcms/ui": ["./packages/ui/src/exports/client/index.ts"],
|
],
|
||||||
"@payloadcms/ui/shared": ["./packages/ui/src/exports/shared/index.ts"],
|
"@payloadcms/live-preview": [
|
||||||
"@payloadcms/ui/rsc": ["./packages/ui/src/exports/rsc/index.ts"],
|
"./packages/live-preview/src"
|
||||||
"@payloadcms/ui/scss": ["./packages/ui/src/scss.scss"],
|
],
|
||||||
"@payloadcms/ui/scss/app.scss": ["./packages/ui/src/scss/app.scss"],
|
"@payloadcms/live-preview-react": [
|
||||||
"@payloadcms/next/*": ["./packages/next/src/exports/*.ts"],
|
"./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": [
|
"@payloadcms/richtext-lexical/client": [
|
||||||
"./packages/richtext-lexical/src/exports/client/index.ts"
|
"./packages/richtext-lexical/src/exports/client/index.ts"
|
||||||
],
|
],
|
||||||
"@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"],
|
"@payloadcms/richtext-lexical/rsc": [
|
||||||
"@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"],
|
"./packages/richtext-lexical/src/exports/server/rsc.ts"
|
||||||
|
],
|
||||||
|
"@payloadcms/richtext-slate/rsc": [
|
||||||
|
"./packages/richtext-slate/src/exports/server/rsc.ts"
|
||||||
|
],
|
||||||
"@payloadcms/richtext-slate/client": [
|
"@payloadcms/richtext-slate/client": [
|
||||||
"./packages/richtext-slate/src/exports/client/index.ts"
|
"./packages/richtext-slate/src/exports/client/index.ts"
|
||||||
],
|
],
|
||||||
"@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"],
|
"@payloadcms/plugin-seo/client": [
|
||||||
"@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"],
|
"./packages/plugin-seo/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-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": [
|
"@payloadcms/plugin-form-builder/client": [
|
||||||
"./packages/plugin-form-builder/src/exports/client.ts"
|
"./packages/plugin-form-builder/src/exports/client.ts"
|
||||||
],
|
],
|
||||||
"@payloadcms/plugin-import-export/rsc": [
|
"@payloadcms/plugin-import-export/rsc": [
|
||||||
"./packages/plugin-import-export/src/exports/rsc.ts"
|
"./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": [
|
"@payloadcms/plugin-multi-tenant/utilities": [
|
||||||
"./packages/plugin-multi-tenant/src/exports/utilities.ts"
|
"./packages/plugin-multi-tenant/src/exports/utilities.ts"
|
||||||
],
|
],
|
||||||
@@ -70,25 +113,42 @@
|
|||||||
"@payloadcms/plugin-multi-tenant/client": [
|
"@payloadcms/plugin-multi-tenant/client": [
|
||||||
"./packages/plugin-multi-tenant/src/exports/client.ts"
|
"./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": [
|
"@payloadcms/plugin-multi-tenant/translations/languages/all": [
|
||||||
"./packages/plugin-multi-tenant/src/translations/index.ts"
|
"./packages/plugin-multi-tenant/src/translations/index.ts"
|
||||||
],
|
],
|
||||||
"@payloadcms/plugin-multi-tenant/translations/languages/*": [
|
"@payloadcms/plugin-multi-tenant/translations/languages/*": [
|
||||||
"./packages/plugin-multi-tenant/src/translations/languages/*.ts"
|
"./packages/plugin-multi-tenant/src/translations/languages/*.ts"
|
||||||
],
|
],
|
||||||
"@payloadcms/next": ["./packages/next/src/exports/*"],
|
"@payloadcms/next": [
|
||||||
"@payloadcms/storage-azure/client": ["./packages/storage-azure/src/exports/client.ts"],
|
"./packages/next/src/exports/*"
|
||||||
"@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"],
|
],
|
||||||
|
"@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": [
|
"@payloadcms/storage-vercel-blob/client": [
|
||||||
"./packages/storage-vercel-blob/src/exports/client.ts"
|
"./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": [
|
"@payloadcms/storage-uploadthing/client": [
|
||||||
"./packages/storage-uploadthing/src/exports/client.ts"
|
"./packages/storage-uploadthing/src/exports/client.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["${configDir}/src"],
|
"include": [
|
||||||
"exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"]
|
"${configDir}/src"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"${configDir}/dist",
|
||||||
|
"${configDir}/build",
|
||||||
|
"${configDir}/temp",
|
||||||
|
"**/*.spec.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user