feat: maintains column state in url (#11387)
Maintains column state in the URL. This makes it possible to share direct links to the list view in a specific column order or active column state, similar to the behavior of filters. This also makes it possible to change both the filters and columns in the same rendering cycle, a requirement of the "list presets" feature being worked on here: #11330. For example: ``` ?columns=%5B"title"%2C"content"%2C"-updatedAt"%2C"createdAt"%2C"id"%5D ``` The `-` prefix denotes that the column is inactive. This strategy performs a single round trip to the server, ultimately simplifying the table columns provider as it no longer needs to request a newly rendered table for itself. Without this change, column state would need to be replaced first, followed by a change to the filters. This would make an unnecessary number of requests to the server and briefly render the UI in a stale state. This all happens behind an optimistic update, where the state of the columns is immediately reflected in the UI while the request takes place in the background. Technically speaking, an additional database query in performed compared to the old strategy, whereas before we'd send the data through the request to avoid this. But this is a necessary tradeoff and doesn't have huge performance implications. One could argue that this is actually a good thing, as the data might have changed in the background which would not have been reflected in the result otherwise.
This commit is contained in:
@@ -1,18 +1,18 @@
|
|||||||
import type {
|
|
||||||
AdminViewServerProps,
|
|
||||||
ListPreferences,
|
|
||||||
ListQuery,
|
|
||||||
ListViewClientProps,
|
|
||||||
ListViewServerPropsOnly,
|
|
||||||
Where,
|
|
||||||
} from 'payload'
|
|
||||||
|
|
||||||
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
|
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
|
||||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||||
import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc'
|
import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc'
|
||||||
import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
|
import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
|
||||||
import { notFound } from 'next/navigation.js'
|
import { notFound } from 'next/navigation.js'
|
||||||
import { isNumber } from 'payload/shared'
|
import {
|
||||||
|
type AdminViewServerProps,
|
||||||
|
type ColumnPreference,
|
||||||
|
type ListPreferences,
|
||||||
|
type ListQuery,
|
||||||
|
type ListViewClientProps,
|
||||||
|
type ListViewServerPropsOnly,
|
||||||
|
type Where,
|
||||||
|
} from 'payload'
|
||||||
|
import { isNumber, transformColumnsToPreferences } from 'payload/shared'
|
||||||
import React, { Fragment } from 'react'
|
import React, { Fragment } from 'react'
|
||||||
|
|
||||||
import { renderListViewSlots } from './renderListViewSlots.js'
|
import { renderListViewSlots } from './renderListViewSlots.js'
|
||||||
@@ -72,10 +72,20 @@ export const renderListView = async (
|
|||||||
|
|
||||||
const query = queryFromArgs || queryFromReq
|
const query = queryFromArgs || queryFromReq
|
||||||
|
|
||||||
|
const columns: ColumnPreference[] = transformColumnsToPreferences(
|
||||||
|
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 listPreferences = await upsertPreferences<ListPreferences>({
|
const listPreferences = await upsertPreferences<ListPreferences>({
|
||||||
key: `${collectionSlug}-list`,
|
key: `${collectionSlug}-list`,
|
||||||
req,
|
req,
|
||||||
value: {
|
value: {
|
||||||
|
columns,
|
||||||
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
|
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
|
||||||
sort: query?.sort as string,
|
sort: query?.sort as string,
|
||||||
},
|
},
|
||||||
@@ -141,6 +151,7 @@ export const renderListView = async (
|
|||||||
clientCollectionConfig,
|
clientCollectionConfig,
|
||||||
collectionConfig,
|
collectionConfig,
|
||||||
columnPreferences: listPreferences?.columns,
|
columnPreferences: listPreferences?.columns,
|
||||||
|
columns,
|
||||||
customCellProps,
|
customCellProps,
|
||||||
docs: data.docs,
|
docs: data.docs,
|
||||||
drawerSlug,
|
drawerSlug,
|
||||||
@@ -203,9 +214,11 @@ export const renderListView = async (
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
<HydrateAuthProvider permissions={permissions} />
|
<HydrateAuthProvider permissions={permissions} />
|
||||||
<ListQueryProvider
|
<ListQueryProvider
|
||||||
|
columns={transformColumnsToPreferences(columnState)}
|
||||||
data={data}
|
data={data}
|
||||||
defaultLimit={limit}
|
defaultLimit={limit}
|
||||||
defaultSort={sort}
|
defaultSort={sort}
|
||||||
|
listPreferences={listPreferences}
|
||||||
modifySearchParams={!isInDrawer}
|
modifySearchParams={!isInDrawer}
|
||||||
>
|
>
|
||||||
{RenderServerComponent({
|
{RenderServerComponent({
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ImportMap } from '../../bin/generateImportMap/index.js'
|
import type { ImportMap } from '../../bin/generateImportMap/index.js'
|
||||||
import type { SanitizedConfig } from '../../config/types.js'
|
import type { SanitizedConfig } from '../../config/types.js'
|
||||||
import type { PaginatedDocs } from '../../database/types.js'
|
import type { PaginatedDocs } from '../../database/types.js'
|
||||||
import type { CollectionSlug } from '../../index.js'
|
import type { CollectionSlug, ColumnPreference } from '../../index.js'
|
||||||
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
|
import type { PayloadRequest, Sort, Where } from '../../types/index.js'
|
||||||
|
import type { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js'
|
||||||
|
|
||||||
export type DefaultServerFunctionArgs = {
|
export type DefaultServerFunctionArgs = {
|
||||||
importMap: ImportMap
|
importMap: ImportMap
|
||||||
@@ -38,6 +39,11 @@ export type ServerFunctionHandler = (
|
|||||||
) => Promise<unknown>
|
) => Promise<unknown>
|
||||||
|
|
||||||
export type ListQuery = {
|
export type ListQuery = {
|
||||||
|
/*
|
||||||
|
* This is an of strings, i.e. `['title', '-slug']`
|
||||||
|
* Use `transformColumnsToPreferences` to convert it back and forth
|
||||||
|
*/
|
||||||
|
columns?: ColumnsFromURL
|
||||||
limit?: string
|
limit?: string
|
||||||
page?: string
|
page?: string
|
||||||
/*
|
/*
|
||||||
@@ -50,7 +56,7 @@ export type ListQuery = {
|
|||||||
|
|
||||||
export type BuildTableStateArgs = {
|
export type BuildTableStateArgs = {
|
||||||
collectionSlug: string | string[]
|
collectionSlug: string | string[]
|
||||||
columns?: { accessor: string; active: boolean }[]
|
columns?: ColumnPreference[]
|
||||||
docs?: PaginatedDocs['docs']
|
docs?: PaginatedDocs['docs']
|
||||||
enableRowSelections?: boolean
|
enableRowSelections?: boolean
|
||||||
parent?: {
|
parent?: {
|
||||||
|
|||||||
@@ -42,8 +42,14 @@ export type ListViewClientProps = {
|
|||||||
disableBulkEdit?: boolean
|
disableBulkEdit?: boolean
|
||||||
enableRowSelections?: boolean
|
enableRowSelections?: boolean
|
||||||
hasCreatePermission: boolean
|
hasCreatePermission: boolean
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
listPreferences?: ListPreferences
|
listPreferences?: ListPreferences
|
||||||
newDocumentURL: string
|
newDocumentURL: string
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
preferenceKey?: string
|
preferenceKey?: string
|
||||||
renderedFilters?: Map<string, React.ReactNode>
|
renderedFilters?: Map<string, React.ReactNode>
|
||||||
resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
|
resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export {
|
|||||||
deepCopyObjectSimple,
|
deepCopyObjectSimple,
|
||||||
deepCopyObjectSimpleWithoutReactComponents,
|
deepCopyObjectSimpleWithoutReactComponents,
|
||||||
} from '../utilities/deepCopyObject.js'
|
} from '../utilities/deepCopyObject.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
deepMerge,
|
deepMerge,
|
||||||
deepMergeWithCombinedArrays,
|
deepMergeWithCombinedArrays,
|
||||||
@@ -60,13 +61,13 @@ export {
|
|||||||
} from '../utilities/deepMerge.js'
|
} from '../utilities/deepMerge.js'
|
||||||
|
|
||||||
export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js'
|
export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js'
|
||||||
|
|
||||||
export { flattenAllFields } from '../utilities/flattenAllFields.js'
|
export { flattenAllFields } from '../utilities/flattenAllFields.js'
|
||||||
export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
|
export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
|
||||||
|
|
||||||
export { getDataByPath } from '../utilities/getDataByPath.js'
|
export { getDataByPath } from '../utilities/getDataByPath.js'
|
||||||
|
|
||||||
export { getSelectMode } from '../utilities/getSelectMode.js'
|
export { getSelectMode } from '../utilities/getSelectMode.js'
|
||||||
export { getSiblingData } from '../utilities/getSiblingData.js'
|
export { getSiblingData } from '../utilities/getSiblingData.js'
|
||||||
|
|
||||||
export { getUniqueListBy } from '../utilities/getUniqueListBy.js'
|
export { getUniqueListBy } from '../utilities/getUniqueListBy.js'
|
||||||
|
|
||||||
export { isNextBuild } from '../utilities/isNextBuild.js'
|
export { isNextBuild } from '../utilities/isNextBuild.js'
|
||||||
@@ -87,6 +88,11 @@ export { setsAreEqual } from '../utilities/setsAreEqual.js'
|
|||||||
|
|
||||||
export { default as toKebabCase } from '../utilities/toKebabCase.js'
|
export { default as toKebabCase } from '../utilities/toKebabCase.js'
|
||||||
|
|
||||||
|
export {
|
||||||
|
transformColumnsToPreferences,
|
||||||
|
transformColumnsToSearchParams,
|
||||||
|
} from '../utilities/transformColumnPreferences.js'
|
||||||
|
|
||||||
export { unflatten } from '../utilities/unflatten.js'
|
export { unflatten } from '../utilities/unflatten.js'
|
||||||
export { validateMimeType } from '../utilities/validateMimeType.js'
|
export { validateMimeType } from '../utilities/validateMimeType.js'
|
||||||
export { wait } from '../utilities/wait.js'
|
export { wait } from '../utilities/wait.js'
|
||||||
|
|||||||
@@ -1374,6 +1374,7 @@ export { restoreVersionOperation as restoreVersionOperationGlobal } from './glob
|
|||||||
export { updateOperation as updateOperationGlobal } from './globals/operations/update.js'
|
export { updateOperation as updateOperationGlobal } from './globals/operations/update.js'
|
||||||
export type {
|
export type {
|
||||||
CollapsedPreferences,
|
CollapsedPreferences,
|
||||||
|
ColumnPreference,
|
||||||
DocumentPreferences,
|
DocumentPreferences,
|
||||||
FieldsPreferences,
|
FieldsPreferences,
|
||||||
InsideFieldsPreferences,
|
InsideFieldsPreferences,
|
||||||
@@ -1411,8 +1412,8 @@ export { getLocalI18n } from './translations/getLocalI18n.js'
|
|||||||
export * from './types/index.js'
|
export * from './types/index.js'
|
||||||
export { getFileByPath } from './uploads/getFileByPath.js'
|
export { getFileByPath } from './uploads/getFileByPath.js'
|
||||||
export type * from './uploads/types.js'
|
export type * from './uploads/types.js'
|
||||||
|
|
||||||
export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
|
export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
|
||||||
|
|
||||||
export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js'
|
export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js'
|
||||||
export { commitTransaction } from './utilities/commitTransaction.js'
|
export { commitTransaction } from './utilities/commitTransaction.js'
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -28,8 +28,13 @@ export type DocumentPreferences = {
|
|||||||
fields: FieldsPreferences
|
fields: FieldsPreferences
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ColumnPreference = {
|
||||||
|
accessor: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type ListPreferences = {
|
export type ListPreferences = {
|
||||||
columns?: { accessor: string; active: boolean }[]
|
columns?: ColumnPreference[]
|
||||||
limit?: number
|
limit?: number
|
||||||
sort?: string
|
sort?: string
|
||||||
}
|
}
|
||||||
|
|||||||
48
packages/payload/src/utilities/transformColumnPreferences.ts
Normal file
48
packages/payload/src/utilities/transformColumnPreferences.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Column } from '../admin/types.js'
|
||||||
|
import type { ColumnPreference } from '../preferences/types.js'
|
||||||
|
|
||||||
|
export type ColumnsFromURL = string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms various forms of columns into `ColumnPreference[]` which is what is stored in the user's preferences table
|
||||||
|
* In React state, for example, columns are stored in in their entirety, including React components: `[{ accessor: 'title', active: true, Label: React.ReactNode, ... }]`
|
||||||
|
* In the URL, they are stored as an array of strings: `['title', '-slug']`, where the `-` prefix is used to indicate that the column is inactive
|
||||||
|
* However in the database, columns must be in this exact shape: `[{ accessor: 'title', active: true }, { accessor: 'slug', active: false }]`
|
||||||
|
* This means that when handling columns, they need to be consistently transformed back and forth
|
||||||
|
*/
|
||||||
|
export const transformColumnsToPreferences = (
|
||||||
|
columns: Column[] | ColumnPreference[] | ColumnsFromURL | string,
|
||||||
|
): ColumnPreference[] | undefined => {
|
||||||
|
let columnsToTransform = columns
|
||||||
|
|
||||||
|
// Columns that originate from the URL are a stringified JSON array and need to be parsed first
|
||||||
|
if (typeof columns === 'string') {
|
||||||
|
try {
|
||||||
|
columnsToTransform = JSON.parse(columns)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing columns', columns, e) // eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columnsToTransform && Array.isArray(columnsToTransform)) {
|
||||||
|
return columnsToTransform.map((col) => {
|
||||||
|
if (typeof col === 'string') {
|
||||||
|
const active = col[0] !== '-'
|
||||||
|
return { accessor: active ? col : col.slice(1), active }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accessor: col.accessor, active: col.active }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the opposite of `transformColumnsToPreferences`, where `ColumnPreference[]` and `Column[]` are transformed into `ColumnsFromURL`
|
||||||
|
* This is useful for storing the columns in the URL, where it appears as a simple comma delimited array of strings
|
||||||
|
* The `-` prefix is used to indicate that the column is inactive
|
||||||
|
*/
|
||||||
|
export const transformColumnsToSearchParams = (
|
||||||
|
columns: Column[] | ColumnPreference[],
|
||||||
|
): ColumnsFromURL => {
|
||||||
|
return columns.map((col) => (col.active ? col.accessor : `-${col.accessor}`))
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { getTranslation } from '@payloadcms/translations'
|
import { getTranslation } from '@payloadcms/translations'
|
||||||
import { useSearchParams } from 'next/navigation.js'
|
import { useRouter } from 'next/navigation.js'
|
||||||
import * as qs from 'qs-esm'
|
import * as qs from 'qs-esm'
|
||||||
import React, { Fragment } from 'react'
|
import React, { Fragment } from 'react'
|
||||||
|
|
||||||
import { useConfig } from '../../providers/Config/index.js'
|
import { useConfig } from '../../providers/Config/index.js'
|
||||||
import { useLocale, useLocaleLoading } from '../../providers/Locale/index.js'
|
import { useLocale, useLocaleLoading } from '../../providers/Locale/index.js'
|
||||||
|
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
|
||||||
import { useTranslation } from '../../providers/Translation/index.js'
|
import { useTranslation } from '../../providers/Translation/index.js'
|
||||||
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
|
|
||||||
import { Popup, PopupList } from '../Popup/index.js'
|
import { Popup, PopupList } from '../Popup/index.js'
|
||||||
import { LocalizerLabel } from './LocalizerLabel/index.js'
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
import { LocalizerLabel } from './LocalizerLabel/index.js'
|
||||||
|
|
||||||
const baseClass = 'localizer'
|
const baseClass = 'localizer'
|
||||||
|
|
||||||
@@ -21,7 +21,10 @@ export const Localizer: React.FC<{
|
|||||||
const {
|
const {
|
||||||
config: { localization },
|
config: { localization },
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { startRouteTransition } = useRouteTransition()
|
||||||
|
|
||||||
const { setLocaleIsLoading } = useLocaleLoading()
|
const { setLocaleIsLoading } = useLocaleLoading()
|
||||||
|
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
@@ -44,17 +47,28 @@ export const Localizer: React.FC<{
|
|||||||
<PopupList.Button
|
<PopupList.Button
|
||||||
active={locale.code === localeOption.code}
|
active={locale.code === localeOption.code}
|
||||||
disabled={locale.code === localeOption.code}
|
disabled={locale.code === localeOption.code}
|
||||||
href={qs.stringify(
|
|
||||||
{
|
|
||||||
...parseSearchParams(searchParams),
|
|
||||||
locale: localeOption.code,
|
|
||||||
},
|
|
||||||
{ addQueryPrefix: true },
|
|
||||||
)}
|
|
||||||
key={localeOption.code}
|
key={localeOption.code}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLocaleIsLoading(true)
|
setLocaleIsLoading(true)
|
||||||
close()
|
close()
|
||||||
|
|
||||||
|
// can't use `useSearchParams` here because it is stale due to `window.history.pushState` in `ListQueryProvider`
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
const url = qs.stringify(
|
||||||
|
{
|
||||||
|
...qs.parse(searchParams.toString(), {
|
||||||
|
depth: 10,
|
||||||
|
ignoreQueryPrefix: true,
|
||||||
|
}),
|
||||||
|
locale: localeOption.code,
|
||||||
|
},
|
||||||
|
{ addQueryPrefix: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
startRouteTransition(() => {
|
||||||
|
router.push(url)
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{localeOptionLabel !== localeOption.code ? (
|
{localeOptionLabel !== localeOption.code ? (
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export function PublishButton({ label: labelProp }: PublishButtonClientProps) {
|
|||||||
)}
|
)}
|
||||||
{localization && canPublish && (
|
{localization && canPublish && (
|
||||||
<PopupList.ButtonGroup>
|
<PopupList.ButtonGroup>
|
||||||
<PopupList.Button onClick={secondaryPublish}>
|
<PopupList.Button id="publish-locale" onClick={secondaryPublish}>
|
||||||
{secondaryLabel}
|
{secondaryLabel}
|
||||||
</PopupList.Button>
|
</PopupList.Button>
|
||||||
</PopupList.ButtonGroup>
|
</PopupList.ButtonGroup>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type {
|
|
||||||
CollectionSlug,
|
|
||||||
Column,
|
|
||||||
JoinFieldClient,
|
|
||||||
ListQuery,
|
|
||||||
PaginatedDocs,
|
|
||||||
Where,
|
|
||||||
} from 'payload'
|
|
||||||
|
|
||||||
import { getTranslation } from '@payloadcms/translations'
|
import { getTranslation } from '@payloadcms/translations'
|
||||||
|
import {
|
||||||
|
type CollectionSlug,
|
||||||
|
type Column,
|
||||||
|
type JoinFieldClient,
|
||||||
|
type ListQuery,
|
||||||
|
type PaginatedDocs,
|
||||||
|
type Where,
|
||||||
|
} from 'payload'
|
||||||
|
import { transformColumnsToPreferences } from 'payload/shared'
|
||||||
import React, { Fragment, useCallback, useEffect, useState } from 'react'
|
import React, { Fragment, useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import type { DocumentDrawerProps } from '../DocumentDrawer/types.js'
|
import type { DocumentDrawerProps } from '../DocumentDrawer/types.js'
|
||||||
@@ -137,7 +137,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
Table: NewTable,
|
Table: NewTable,
|
||||||
} = await getTableState({
|
} = await getTableState({
|
||||||
collectionSlug: relationTo,
|
collectionSlug: relationTo,
|
||||||
columns: defaultColumns,
|
columns: transformColumnsToPreferences(query?.columns) || defaultColumns,
|
||||||
docs,
|
docs,
|
||||||
enableRowSelections: false,
|
enableRowSelections: false,
|
||||||
parent,
|
parent,
|
||||||
@@ -154,7 +154,6 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
[
|
[
|
||||||
field.defaultLimit,
|
field.defaultLimit,
|
||||||
field.defaultSort,
|
field.defaultSort,
|
||||||
field.admin.defaultColumns,
|
|
||||||
collectionConfig?.admin?.pagination?.defaultLimit,
|
collectionConfig?.admin?.pagination?.defaultLimit,
|
||||||
collectionConfig?.defaultSort,
|
collectionConfig?.defaultSort,
|
||||||
query,
|
query,
|
||||||
@@ -215,8 +214,6 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
[data?.docs, renderTable],
|
[data?.docs, renderTable],
|
||||||
)
|
)
|
||||||
|
|
||||||
const preferenceKey = `${Array.isArray(relationTo) ? `${parent.collectionSlug}-${parent.joinPath}` : relationTo}-list`
|
|
||||||
|
|
||||||
const canCreate =
|
const canCreate =
|
||||||
allowCreate !== false &&
|
allowCreate !== false &&
|
||||||
permissions?.collections?.[Array.isArray(relationTo) ? relationTo[0] : relationTo]?.create
|
permissions?.collections?.[Array.isArray(relationTo) ? relationTo[0] : relationTo]?.create
|
||||||
@@ -326,6 +323,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={
|
defaultLimit={
|
||||||
field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit
|
field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit
|
||||||
@@ -336,17 +334,9 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
|
|||||||
<TableColumnsProvider
|
<TableColumnsProvider
|
||||||
collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}
|
collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}
|
||||||
columnState={columnState}
|
columnState={columnState}
|
||||||
docs={data.docs}
|
|
||||||
LinkedCellOverride={
|
LinkedCellOverride={
|
||||||
<DrawerLink onDrawerDelete={onDrawerDelete} onDrawerSave={onDrawerSave} />
|
<DrawerLink onDrawerDelete={onDrawerDelete} onDrawerSave={onDrawerSave} />
|
||||||
}
|
}
|
||||||
preferenceKey={preferenceKey}
|
|
||||||
renderRowTypes
|
|
||||||
setTable={setTable}
|
|
||||||
sortColumnProps={{
|
|
||||||
appearance: 'condensed',
|
|
||||||
}}
|
|
||||||
tableAppearance="condensed"
|
|
||||||
>
|
>
|
||||||
<AnimateHeight
|
<AnimateHeight
|
||||||
className={`${baseClass}__columns`}
|
className={`${baseClass}__columns`}
|
||||||
|
|||||||
7
packages/ui/src/elements/TableColumns/context.ts
Normal file
7
packages/ui/src/elements/TableColumns/context.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
import type { ITableColumns } from './types.js'
|
||||||
|
|
||||||
|
export const TableColumnContext = createContext<ITableColumns>({} as ITableColumns)
|
||||||
|
|
||||||
|
export const useTableColumns = (): ITableColumns => useContext(TableColumnContext)
|
||||||
@@ -1,293 +1,98 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { Column, ListPreferences, SanitizedCollectionConfig } from 'payload'
|
import { type Column } from 'payload'
|
||||||
|
import { transformColumnsToSearchParams } from 'payload/shared'
|
||||||
|
import React, { startTransition, useCallback } from 'react'
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useEffect } from 'react'
|
import type { TableColumnsProviderProps } from './types.js'
|
||||||
|
|
||||||
import type { SortColumnProps } from '../SortColumn/index.js'
|
|
||||||
|
|
||||||
import { useConfig } from '../../providers/Config/index.js'
|
import { useConfig } from '../../providers/Config/index.js'
|
||||||
import { usePreferences } from '../../providers/Preferences/index.js'
|
import { useListQuery } from '../../providers/ListQuery/index.js'
|
||||||
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
|
import { TableColumnContext } from './context.js'
|
||||||
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
|
|
||||||
|
|
||||||
export interface ITableColumns {
|
export { useTableColumns } from './context.js'
|
||||||
columns: Column[]
|
|
||||||
LinkedCellOverride?: React.ReactNode
|
|
||||||
moveColumn: (args: { fromIndex: number; toIndex: number }) => Promise<void>
|
|
||||||
resetColumnsState: () => Promise<void>
|
|
||||||
setActiveColumns: (columns: string[]) => Promise<void>
|
|
||||||
toggleColumn: (column: string) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableColumnContext = createContext<ITableColumns>({} as ITableColumns)
|
export const TableColumnsProvider: React.FC<TableColumnsProviderProps> = ({
|
||||||
|
|
||||||
export const useTableColumns = (): ITableColumns => useContext(TableColumnContext)
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
readonly children: React.ReactNode
|
|
||||||
readonly collectionSlug: string | string[]
|
|
||||||
readonly columnState: Column[]
|
|
||||||
readonly docs: any[]
|
|
||||||
readonly enableRowSelections?: boolean
|
|
||||||
readonly LinkedCellOverride?: React.ReactNode
|
|
||||||
readonly listPreferences?: ListPreferences
|
|
||||||
readonly preferenceKey: string
|
|
||||||
readonly renderRowTypes?: boolean
|
|
||||||
readonly setTable: (Table: React.ReactNode) => void
|
|
||||||
readonly sortColumnProps?: Partial<SortColumnProps>
|
|
||||||
readonly tableAppearance?: 'condensed' | 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
// strip out Heading, Label, and renderedCells properties, they cannot be sent to the server
|
|
||||||
const sanitizeColumns = (columns: Column[]) => {
|
|
||||||
return columns.map(({ accessor, active }) => ({
|
|
||||||
accessor,
|
|
||||||
active,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableColumnsProvider: React.FC<Props> = ({
|
|
||||||
children,
|
children,
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
columnState,
|
columnState: columnStateFromProps,
|
||||||
docs,
|
|
||||||
enableRowSelections,
|
|
||||||
LinkedCellOverride,
|
LinkedCellOverride,
|
||||||
listPreferences,
|
|
||||||
preferenceKey,
|
|
||||||
renderRowTypes,
|
|
||||||
setTable,
|
|
||||||
sortColumnProps,
|
|
||||||
tableAppearance,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { getEntityConfig } = useConfig()
|
const { getEntityConfig } = useConfig()
|
||||||
|
const { query: currentQuery, refineListData } = useListQuery()
|
||||||
|
|
||||||
const { getTableState } = useServerFunctions()
|
const { admin: { defaultColumns } = {} } = getEntityConfig({
|
||||||
|
|
||||||
const { admin: { defaultColumns, useAsTitle } = {}, fields } = getEntityConfig({
|
|
||||||
collectionSlug,
|
collectionSlug,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prevCollection = React.useRef<SanitizedCollectionConfig['slug']>(
|
const [columnState, setOptimisticColumnState] = React.useOptimistic(
|
||||||
Array.isArray(collectionSlug) ? collectionSlug[0] : collectionSlug,
|
columnStateFromProps,
|
||||||
)
|
(state, action: Column[]) => action,
|
||||||
const { getPreference } = usePreferences()
|
|
||||||
|
|
||||||
const [tableColumns, setTableColumns] = React.useState(columnState)
|
|
||||||
const abortTableStateRef = React.useRef<AbortController>(null)
|
|
||||||
const abortToggleColumnRef = React.useRef<AbortController>(null)
|
|
||||||
|
|
||||||
const moveColumn = useCallback(
|
|
||||||
async (args: { fromIndex: number; toIndex: number }) => {
|
|
||||||
const controller = handleAbortRef(abortTableStateRef)
|
|
||||||
|
|
||||||
const { fromIndex, toIndex } = args
|
|
||||||
const withMovedColumn = [...tableColumns]
|
|
||||||
const [columnToMove] = withMovedColumn.splice(fromIndex, 1)
|
|
||||||
withMovedColumn.splice(toIndex, 0, columnToMove)
|
|
||||||
|
|
||||||
setTableColumns(withMovedColumn)
|
|
||||||
|
|
||||||
const result = await getTableState({
|
|
||||||
collectionSlug,
|
|
||||||
columns: sanitizeColumns(withMovedColumn),
|
|
||||||
docs,
|
|
||||||
enableRowSelections,
|
|
||||||
renderRowTypes,
|
|
||||||
signal: controller.signal,
|
|
||||||
tableAppearance,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
setTableColumns(result.state)
|
|
||||||
setTable(result.Table)
|
|
||||||
}
|
|
||||||
|
|
||||||
abortTableStateRef.current = null
|
|
||||||
},
|
|
||||||
[
|
|
||||||
tableColumns,
|
|
||||||
collectionSlug,
|
|
||||||
docs,
|
|
||||||
getTableState,
|
|
||||||
setTable,
|
|
||||||
enableRowSelections,
|
|
||||||
renderRowTypes,
|
|
||||||
tableAppearance,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleColumn = useCallback(
|
const toggleColumn = useCallback(
|
||||||
async (column: string) => {
|
async (column: string) => {
|
||||||
const controller = handleAbortRef(abortToggleColumnRef)
|
const newColumnState = (columnState || []).map((col) => {
|
||||||
|
if (col.accessor === column) {
|
||||||
const { newColumnState, toggledColumns } = tableColumns.reduce<{
|
return { ...col, active: !col.active }
|
||||||
newColumnState: Column[]
|
}
|
||||||
toggledColumns: Pick<Column, 'accessor' | 'active'>[]
|
return col
|
||||||
}>(
|
|
||||||
(acc, col) => {
|
|
||||||
if (col.accessor === column) {
|
|
||||||
acc.newColumnState.push({
|
|
||||||
...col,
|
|
||||||
accessor: col.accessor,
|
|
||||||
active: !col.active,
|
|
||||||
})
|
|
||||||
acc.toggledColumns.push({
|
|
||||||
accessor: col.accessor,
|
|
||||||
active: !col.active,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
acc.newColumnState.push(col)
|
|
||||||
acc.toggledColumns.push({
|
|
||||||
accessor: col.accessor,
|
|
||||||
active: col.active,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc
|
|
||||||
},
|
|
||||||
{ newColumnState: [], toggledColumns: [] },
|
|
||||||
)
|
|
||||||
|
|
||||||
setTableColumns(newColumnState)
|
|
||||||
|
|
||||||
const result = await getTableState({
|
|
||||||
collectionSlug,
|
|
||||||
columns: toggledColumns,
|
|
||||||
docs,
|
|
||||||
enableRowSelections,
|
|
||||||
renderRowTypes,
|
|
||||||
signal: controller.signal,
|
|
||||||
tableAppearance,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result) {
|
startTransition(() => {
|
||||||
setTableColumns(result.state)
|
setOptimisticColumnState(newColumnState)
|
||||||
setTable(result.Table)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
abortToggleColumnRef.current = null
|
await refineListData({
|
||||||
|
columns: transformColumnsToSearchParams(newColumnState),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[
|
[refineListData, columnState, setOptimisticColumnState],
|
||||||
tableColumns,
|
|
||||||
getTableState,
|
|
||||||
setTable,
|
|
||||||
collectionSlug,
|
|
||||||
docs,
|
|
||||||
enableRowSelections,
|
|
||||||
renderRowTypes,
|
|
||||||
tableAppearance,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const setActiveColumns = React.useCallback(
|
const moveColumn = useCallback(
|
||||||
async (activeColumnAccessors: string[]) => {
|
async (args: { fromIndex: number; toIndex: number }) => {
|
||||||
const activeColumns: Pick<Column, 'accessor' | 'active'>[] = tableColumns
|
const { fromIndex, toIndex } = args
|
||||||
.map((col) => {
|
const newColumnState = [...(columnState || [])]
|
||||||
return {
|
const [columnToMove] = newColumnState.splice(fromIndex, 1)
|
||||||
accessor: col.accessor,
|
newColumnState.splice(toIndex, 0, columnToMove)
|
||||||
active: activeColumnAccessors.includes(col.accessor),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort((first, second) => {
|
|
||||||
const indexOfFirst = activeColumnAccessors.indexOf(first.accessor)
|
|
||||||
const indexOfSecond = activeColumnAccessors.indexOf(second.accessor)
|
|
||||||
|
|
||||||
if (indexOfFirst === -1 || indexOfSecond === -1) {
|
startTransition(() => {
|
||||||
return 0
|
setOptimisticColumnState(newColumnState)
|
||||||
}
|
|
||||||
|
|
||||||
return indexOfFirst > indexOfSecond ? 1 : -1
|
|
||||||
})
|
|
||||||
|
|
||||||
const { state: columnState, Table } = await getTableState({
|
|
||||||
collectionSlug,
|
|
||||||
columns: activeColumns,
|
|
||||||
docs,
|
|
||||||
enableRowSelections,
|
|
||||||
renderRowTypes,
|
|
||||||
tableAppearance,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setTableColumns(columnState)
|
await refineListData({
|
||||||
setTable(Table)
|
columns: transformColumnsToSearchParams(newColumnState),
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[
|
[columnState, refineListData, setOptimisticColumnState],
|
||||||
tableColumns,
|
)
|
||||||
getTableState,
|
|
||||||
setTable,
|
const setActiveColumns = useCallback(
|
||||||
collectionSlug,
|
async (columns: string[]) => {
|
||||||
docs,
|
const newColumnState = currentQuery.columns
|
||||||
enableRowSelections,
|
|
||||||
renderRowTypes,
|
columns.forEach((colName) => {
|
||||||
tableAppearance,
|
const colIndex = newColumnState.findIndex((c) => colName === c)
|
||||||
],
|
|
||||||
|
// ensure the name does not begin with a `-` which denotes an inactive column
|
||||||
|
if (colIndex !== undefined && newColumnState[colIndex][0] === '-') {
|
||||||
|
newColumnState[colIndex] = colName.slice(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await refineListData({ columns: newColumnState })
|
||||||
|
},
|
||||||
|
[currentQuery, refineListData],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetColumnsState = React.useCallback(async () => {
|
const resetColumnsState = React.useCallback(async () => {
|
||||||
await setActiveColumns(defaultColumns)
|
await setActiveColumns(defaultColumns)
|
||||||
}, [defaultColumns, setActiveColumns])
|
}, [defaultColumns, setActiveColumns])
|
||||||
|
|
||||||
// //////////////////////////////////////////////
|
|
||||||
// Get preferences on collection change (drawers)
|
|
||||||
// //////////////////////////////////////////////
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const sync = async () => {
|
|
||||||
const defaultCollection = Array.isArray(collectionSlug) ? collectionSlug[0] : collectionSlug
|
|
||||||
const collectionHasChanged = prevCollection.current !== defaultCollection
|
|
||||||
|
|
||||||
if (collectionHasChanged || !listPreferences) {
|
|
||||||
const currentPreferences = await getPreference<{
|
|
||||||
columns: ListPreferences['columns']
|
|
||||||
}>(preferenceKey)
|
|
||||||
|
|
||||||
prevCollection.current = defaultCollection
|
|
||||||
|
|
||||||
if (currentPreferences?.columns) {
|
|
||||||
// setTableColumns()
|
|
||||||
// buildColumnState({
|
|
||||||
// beforeRows,
|
|
||||||
// columnPreferences: currentPreferences?.columns,
|
|
||||||
// columns: initialColumns,
|
|
||||||
// enableRowSelections,
|
|
||||||
// fields,
|
|
||||||
// sortColumnProps,
|
|
||||||
// useAsTitle,
|
|
||||||
// }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void sync()
|
|
||||||
}, [
|
|
||||||
preferenceKey,
|
|
||||||
getPreference,
|
|
||||||
collectionSlug,
|
|
||||||
fields,
|
|
||||||
defaultColumns,
|
|
||||||
useAsTitle,
|
|
||||||
listPreferences,
|
|
||||||
enableRowSelections,
|
|
||||||
sortColumnProps,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTableColumns(columnState)
|
|
||||||
}, [columnState])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const abortTableState = abortTableStateRef.current
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
abortAndIgnore(abortTableState)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableColumnContext.Provider
|
<TableColumnContext.Provider
|
||||||
value={{
|
value={{
|
||||||
columns: tableColumns,
|
columns: columnState,
|
||||||
LinkedCellOverride,
|
LinkedCellOverride,
|
||||||
moveColumn,
|
moveColumn,
|
||||||
resetColumnsState,
|
resetColumnsState,
|
||||||
|
|||||||
51
packages/ui/src/elements/TableColumns/types.ts
Normal file
51
packages/ui/src/elements/TableColumns/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Column, ListPreferences } from 'payload'
|
||||||
|
|
||||||
|
import type { SortColumnProps } from '../SortColumn/index.js'
|
||||||
|
|
||||||
|
export interface ITableColumns {
|
||||||
|
columns: Column[]
|
||||||
|
LinkedCellOverride?: React.ReactNode
|
||||||
|
moveColumn: (args: { fromIndex: number; toIndex: number }) => Promise<void>
|
||||||
|
resetColumnsState: () => Promise<void>
|
||||||
|
setActiveColumns: (columns: string[]) => Promise<void>
|
||||||
|
toggleColumn: (column: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TableColumnsProviderProps = {
|
||||||
|
readonly children: React.ReactNode
|
||||||
|
readonly collectionSlug: string | string[]
|
||||||
|
readonly columnState: Column[]
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly docs?: any[]
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly enableRowSelections?: boolean
|
||||||
|
readonly LinkedCellOverride?: React.ReactNode
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly listPreferences?: ListPreferences
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly preferenceKey?: string
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly renderRowTypes?: boolean
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly setTable?: (Table: React.ReactNode) => void
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly sortColumnProps?: Partial<SortColumnProps>
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly tableAppearance?: 'condensed' | 'default'
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import type { AddCondition, ReducedField, UpdateCondition } from '../types.js'
|
import type { AddCondition, ReducedField, RemoveCondition, UpdateCondition } from '../types.js'
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
readonly addCondition: AddCondition
|
readonly addCondition: AddCondition
|
||||||
@@ -11,7 +11,7 @@ export type Props = {
|
|||||||
readonly operator: Operator
|
readonly operator: Operator
|
||||||
readonly orIndex: number
|
readonly orIndex: number
|
||||||
readonly reducedFields: ReducedField[]
|
readonly reducedFields: ReducedField[]
|
||||||
readonly removeCondition: ({ andIndex, orIndex }: { andIndex: number; orIndex: number }) => void
|
readonly removeCondition: RemoveCondition
|
||||||
readonly RenderedFilter: React.ReactNode
|
readonly RenderedFilter: React.ReactNode
|
||||||
readonly updateCondition: UpdateCondition
|
readonly updateCondition: UpdateCondition
|
||||||
readonly value: string
|
readonly value: string
|
||||||
@@ -67,9 +67,9 @@ export const Condition: React.FC<Props> = (props) => {
|
|||||||
valueOptions = reducedField.field.options
|
valueOptions = reducedField.field.options
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateValue = useEffectEvent((debouncedValue) => {
|
const updateValue = useEffectEvent(async (debouncedValue) => {
|
||||||
if (operator) {
|
if (operator) {
|
||||||
updateCondition({
|
await updateCondition({
|
||||||
andIndex,
|
andIndex,
|
||||||
field: reducedField,
|
field: reducedField,
|
||||||
operator,
|
operator,
|
||||||
@@ -80,7 +80,7 @@ export const Condition: React.FC<Props> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateValue(debouncedValue)
|
void updateValue(debouncedValue)
|
||||||
}, [debouncedValue])
|
}, [debouncedValue])
|
||||||
|
|
||||||
const disabled =
|
const disabled =
|
||||||
@@ -88,9 +88,9 @@ export const Condition: React.FC<Props> = (props) => {
|
|||||||
reducedField?.field?.admin?.disableListFilter
|
reducedField?.field?.admin?.disableListFilter
|
||||||
|
|
||||||
const handleFieldChange = useCallback(
|
const handleFieldChange = useCallback(
|
||||||
(field: Option<string>) => {
|
async (field: Option<string>) => {
|
||||||
setInternalValue(undefined)
|
setInternalValue(undefined)
|
||||||
updateCondition({
|
await updateCondition({
|
||||||
andIndex,
|
andIndex,
|
||||||
field: reducedFields.find((option) => option.value === field.value),
|
field: reducedFields.find((option) => option.value === field.value),
|
||||||
operator,
|
operator,
|
||||||
@@ -102,8 +102,8 @@ export const Condition: React.FC<Props> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleOperatorChange = useCallback(
|
const handleOperatorChange = useCallback(
|
||||||
(operator: Option<Operator>) => {
|
async (operator: Option<Operator>) => {
|
||||||
updateCondition({
|
await updateCondition({
|
||||||
andIndex,
|
andIndex,
|
||||||
field: reducedField,
|
field: reducedField,
|
||||||
operator: operator.value,
|
operator: operator.value,
|
||||||
|
|||||||
@@ -4,18 +4,17 @@ import type { Operator, Where } from 'payload'
|
|||||||
import { getTranslation } from '@payloadcms/translations'
|
import { getTranslation } from '@payloadcms/translations'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
|
|
||||||
import type { AddCondition, UpdateCondition, WhereBuilderProps } from './types.js'
|
import type { AddCondition, RemoveCondition, UpdateCondition, WhereBuilderProps } from './types.js'
|
||||||
|
|
||||||
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
|
||||||
import { useListQuery } from '../../providers/ListQuery/index.js'
|
import { useListQuery } from '../../providers/ListQuery/index.js'
|
||||||
import { useTranslation } from '../../providers/Translation/index.js'
|
import { useTranslation } from '../../providers/Translation/index.js'
|
||||||
import { Button } from '../Button/index.js'
|
import { Button } from '../Button/index.js'
|
||||||
import { Condition } from './Condition/index.js'
|
import { Condition } from './Condition/index.js'
|
||||||
import fieldTypes from './field-types.js'
|
import fieldTypes from './field-types.js'
|
||||||
import { reduceFields } from './reduceFields.js'
|
import { reduceFields } from './reduceFields.js'
|
||||||
import './index.scss'
|
|
||||||
import { transformWhereQuery } from './transformWhereQuery.js'
|
import { transformWhereQuery } from './transformWhereQuery.js'
|
||||||
import validateWhereQuery from './validateWhereQuery.js'
|
import validateWhereQuery from './validateWhereQuery.js'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
const baseClass = 'where-builder'
|
const baseClass = 'where-builder'
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
|||||||
const reducedFields = useMemo(() => reduceFields({ fields, i18n }), [fields, i18n])
|
const reducedFields = useMemo(() => reduceFields({ fields, i18n }), [fields, i18n])
|
||||||
|
|
||||||
const { handleWhereChange, query } = useListQuery()
|
const { handleWhereChange, query } = useListQuery()
|
||||||
const [shouldUpdateQuery, setShouldUpdateQuery] = React.useState(false)
|
|
||||||
|
|
||||||
const [conditions, setConditions] = React.useState<Where[]>(() => {
|
const [conditions, setConditions] = React.useState<Where[]>(() => {
|
||||||
const whereFromSearch = query.where
|
const whereFromSearch = query.where
|
||||||
@@ -56,7 +54,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const addCondition: AddCondition = React.useCallback(
|
const addCondition: AddCondition = React.useCallback(
|
||||||
({ andIndex, field, orIndex, relation }) => {
|
async ({ andIndex, field, orIndex, relation }) => {
|
||||||
const newConditions = [...conditions]
|
const newConditions = [...conditions]
|
||||||
|
|
||||||
const defaultOperator = fieldTypes[field.field.type].operators[0].value
|
const defaultOperator = fieldTypes[field.field.type].operators[0].value
|
||||||
@@ -80,12 +78,13 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setConditions(newConditions)
|
setConditions(newConditions)
|
||||||
|
await handleWhereChange({ or: conditions })
|
||||||
},
|
},
|
||||||
[conditions],
|
[conditions, handleWhereChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateCondition: UpdateCondition = React.useCallback(
|
const updateCondition: UpdateCondition = React.useCallback(
|
||||||
({ andIndex, field, operator: incomingOperator, orIndex, value: valueArg }) => {
|
async ({ andIndex, field, operator: incomingOperator, orIndex, value: valueArg }) => {
|
||||||
const existingRowCondition = conditions[orIndex].and[andIndex]
|
const existingRowCondition = conditions[orIndex].and[andIndex]
|
||||||
|
|
||||||
const defaults = fieldTypes[field.field.type]
|
const defaults = fieldTypes[field.field.type]
|
||||||
@@ -102,14 +101,14 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
|||||||
newConditions[orIndex].and[andIndex] = newRowCondition
|
newConditions[orIndex].and[andIndex] = newRowCondition
|
||||||
|
|
||||||
setConditions(newConditions)
|
setConditions(newConditions)
|
||||||
setShouldUpdateQuery(true)
|
await handleWhereChange({ or: conditions })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conditions],
|
[conditions, handleWhereChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const removeCondition = React.useCallback(
|
const removeCondition: RemoveCondition = React.useCallback(
|
||||||
({ andIndex, orIndex }) => {
|
async ({ andIndex, orIndex }) => {
|
||||||
const newConditions = [...conditions]
|
const newConditions = [...conditions]
|
||||||
newConditions[orIndex].and.splice(andIndex, 1)
|
newConditions[orIndex].and.splice(andIndex, 1)
|
||||||
|
|
||||||
@@ -118,21 +117,10 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setConditions(newConditions)
|
setConditions(newConditions)
|
||||||
setShouldUpdateQuery(true)
|
|
||||||
},
|
|
||||||
[conditions],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleChange = useEffectEvent(async (conditions: Where[]) => {
|
|
||||||
if (shouldUpdateQuery) {
|
|
||||||
await handleWhereChange({ or: conditions })
|
await handleWhereChange({ or: conditions })
|
||||||
setShouldUpdateQuery(false)
|
},
|
||||||
}
|
[conditions, handleWhereChange],
|
||||||
})
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void handleChange(conditions)
|
|
||||||
}, [conditions])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
@@ -191,8 +179,8 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
|||||||
icon="plus"
|
icon="plus"
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
iconStyle="with-border"
|
iconStyle="with-border"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
addCondition({
|
await addCondition({
|
||||||
andIndex: 0,
|
andIndex: 0,
|
||||||
field: reducedFields[0],
|
field: reducedFields[0],
|
||||||
orIndex: conditions.length,
|
orIndex: conditions.length,
|
||||||
@@ -213,9 +201,9 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
|||||||
icon="plus"
|
icon="plus"
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
iconStyle="with-border"
|
iconStyle="with-border"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (reducedFields.length > 0) {
|
if (reducedFields.length > 0) {
|
||||||
addCondition({
|
await addCondition({
|
||||||
andIndex: 0,
|
andIndex: 0,
|
||||||
field: reducedFields.find((field) => !field.field.admin?.disableListFilter),
|
field: reducedFields.find((field) => !field.field.admin?.disableListFilter),
|
||||||
orIndex: conditions.length,
|
orIndex: conditions.length,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export type AddCondition = ({
|
|||||||
field: ReducedField
|
field: ReducedField
|
||||||
orIndex: number
|
orIndex: number
|
||||||
relation: 'and' | 'or'
|
relation: 'and' | 'or'
|
||||||
}) => void
|
}) => Promise<void> | void
|
||||||
|
|
||||||
export type UpdateCondition = ({
|
export type UpdateCondition = ({
|
||||||
andIndex,
|
andIndex,
|
||||||
@@ -79,4 +79,12 @@ export type UpdateCondition = ({
|
|||||||
operator: string
|
operator: string
|
||||||
orIndex: number
|
orIndex: number
|
||||||
value: string
|
value: string
|
||||||
}) => void
|
}) => Promise<void> | void
|
||||||
|
|
||||||
|
export type RemoveCondition = ({
|
||||||
|
andIndex,
|
||||||
|
orIndex,
|
||||||
|
}: {
|
||||||
|
andIndex: number
|
||||||
|
orIndex: number
|
||||||
|
}) => Promise<void> | void
|
||||||
|
|||||||
7
packages/ui/src/providers/ListQuery/context.ts
Normal file
7
packages/ui/src/providers/ListQuery/context.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
import type { IListQueryContext } from './types.js'
|
||||||
|
|
||||||
|
export const ListQueryContext = createContext({} as IListQueryContext)
|
||||||
|
|
||||||
|
export const useListQuery = (): IListQueryContext => useContext(ListQueryContext)
|
||||||
@@ -1,57 +1,39 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { ListQuery, PaginatedDocs, Sort, Where } from 'payload'
|
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation.js'
|
import { useRouter, useSearchParams } from 'next/navigation.js'
|
||||||
import { isNumber } from 'payload/shared'
|
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, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import type { ListQueryProps } from './types.js'
|
||||||
|
|
||||||
import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js'
|
import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js'
|
||||||
|
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
||||||
|
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
|
||||||
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
|
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
|
||||||
|
import { ListQueryContext } from './context.js'
|
||||||
|
|
||||||
type ContextHandlers = {
|
export { useListQuery } from './context.js'
|
||||||
handlePageChange?: (page: number) => Promise<void>
|
|
||||||
handlePerPageChange?: (limit: number) => Promise<void>
|
|
||||||
handleSearchChange?: (search: string) => Promise<void>
|
|
||||||
handleSortChange?: (sort: string) => Promise<void>
|
|
||||||
handleWhereChange?: (where: Where) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ListQueryProps = {
|
|
||||||
readonly children: React.ReactNode
|
|
||||||
readonly collectionSlug?: string
|
|
||||||
readonly data: PaginatedDocs
|
|
||||||
readonly defaultLimit?: number
|
|
||||||
readonly defaultSort?: Sort
|
|
||||||
readonly modifySearchParams?: boolean
|
|
||||||
readonly onQueryChange?: (query: ListQuery) => void
|
|
||||||
readonly preferenceKey?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ListQueryContext = {
|
|
||||||
data: PaginatedDocs
|
|
||||||
defaultLimit?: number
|
|
||||||
defaultSort?: Sort
|
|
||||||
query: ListQuery
|
|
||||||
refineListData: (args: ListQuery) => Promise<void>
|
|
||||||
} & ContextHandlers
|
|
||||||
|
|
||||||
const Context = createContext({} as ListQueryContext)
|
|
||||||
|
|
||||||
export const useListQuery = (): ListQueryContext => useContext(Context)
|
|
||||||
|
|
||||||
export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
||||||
children,
|
children,
|
||||||
|
columns,
|
||||||
data,
|
data,
|
||||||
defaultLimit,
|
defaultLimit,
|
||||||
defaultSort,
|
defaultSort,
|
||||||
|
listPreferences,
|
||||||
modifySearchParams,
|
modifySearchParams,
|
||||||
onQueryChange: onQueryChangeFromProps,
|
onQueryChange: onQueryChangeFromProps,
|
||||||
}) => {
|
}) => {
|
||||||
'use no memo'
|
'use no memo'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const rawSearchParams = useSearchParams()
|
const rawSearchParams = useSearchParams()
|
||||||
const searchParams = useMemo(() => parseSearchParams(rawSearchParams), [rawSearchParams])
|
const { startRouteTransition } = useRouteTransition()
|
||||||
|
|
||||||
|
const searchParams = useMemo<ListQuery>(
|
||||||
|
() => parseSearchParams(rawSearchParams),
|
||||||
|
[rawSearchParams],
|
||||||
|
)
|
||||||
|
|
||||||
const { onQueryChange } = useListDrawerContext()
|
const { onQueryChange } = useListDrawerContext()
|
||||||
|
|
||||||
@@ -63,8 +45,6 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentQueryRef = React.useRef(currentQuery)
|
|
||||||
|
|
||||||
// If the search params change externally, update the current query
|
// If the search params change externally, update the current query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modifySearchParams) {
|
if (modifySearchParams) {
|
||||||
@@ -82,6 +62,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newQuery: ListQuery = {
|
const newQuery: ListQuery = {
|
||||||
|
columns: 'columns' in query ? query.columns : currentQuery.columns,
|
||||||
limit: 'limit' in query ? query.limit : (currentQuery?.limit ?? String(defaultLimit)),
|
limit: 'limit' in query ? query.limit : (currentQuery?.limit ?? String(defaultLimit)),
|
||||||
page,
|
page,
|
||||||
search: 'search' in query ? query.search : currentQuery?.search,
|
search: 'search' in query ? query.search : currentQuery?.search,
|
||||||
@@ -90,7 +71,14 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (modifySearchParams) {
|
if (modifySearchParams) {
|
||||||
router.replace(`${qs.stringify(newQuery, { addQueryPrefix: true })}`)
|
startRouteTransition(() =>
|
||||||
|
router.replace(
|
||||||
|
`${qs.stringify(
|
||||||
|
{ ...newQuery, columns: JSON.stringify(newQuery.columns) },
|
||||||
|
{ addQueryPrefix: true },
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
} else if (
|
} else if (
|
||||||
typeof onQueryChange === 'function' ||
|
typeof onQueryChange === 'function' ||
|
||||||
typeof onQueryChangeFromProps === 'function'
|
typeof onQueryChangeFromProps === 'function'
|
||||||
@@ -102,11 +90,13 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
setCurrentQuery(newQuery)
|
setCurrentQuery(newQuery)
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
currentQuery?.page,
|
currentQuery?.columns,
|
||||||
currentQuery?.limit,
|
currentQuery?.limit,
|
||||||
|
currentQuery?.page,
|
||||||
currentQuery?.search,
|
currentQuery?.search,
|
||||||
currentQuery?.sort,
|
currentQuery?.sort,
|
||||||
currentQuery?.where,
|
currentQuery?.where,
|
||||||
|
startRouteTransition,
|
||||||
defaultLimit,
|
defaultLimit,
|
||||||
defaultSort,
|
defaultSort,
|
||||||
modifySearchParams,
|
modifySearchParams,
|
||||||
@@ -152,35 +142,50 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
[refineListData],
|
[refineListData],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const syncQuery = useEffectEvent(() => {
|
||||||
|
let shouldUpdateQueryString = false
|
||||||
|
const newQuery = { ...(currentQuery || {}) }
|
||||||
|
|
||||||
|
// Allow the URL to override the default limit
|
||||||
|
if (isNumber(defaultLimit) && !('limit' in currentQuery)) {
|
||||||
|
newQuery.limit = String(defaultLimit)
|
||||||
|
shouldUpdateQueryString = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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) })}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// If `defaultLimit` or `defaultSort` are updated externally, update the query
|
// If `defaultLimit` or `defaultSort` are updated externally, update the query
|
||||||
// I.e. when HMR runs, these properties may be different
|
// I.e. when HMR runs, these properties may be different
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modifySearchParams) {
|
if (modifySearchParams) {
|
||||||
let shouldUpdateQueryString = false
|
syncQuery()
|
||||||
const newQuery = { ...(currentQueryRef.current || {}) }
|
|
||||||
|
|
||||||
// Allow the URL to override the default limit
|
|
||||||
if (isNumber(defaultLimit) && !('limit' in currentQueryRef.current)) {
|
|
||||||
newQuery.limit = String(defaultLimit)
|
|
||||||
shouldUpdateQueryString = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow the URL to override the default sort
|
|
||||||
if (defaultSort && !('sort' in currentQueryRef.current)) {
|
|
||||||
newQuery.sort = defaultSort
|
|
||||||
shouldUpdateQueryString = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldUpdateQueryString) {
|
|
||||||
setCurrentQuery(newQuery)
|
|
||||||
// Do not use router.replace here to avoid re-rendering on initial load
|
|
||||||
window.history.replaceState(null, '', `?${qs.stringify(newQuery)}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [defaultSort, defaultLimit, router, modifySearchParams])
|
}, [defaultSort, defaultLimit, modifySearchParams, columns])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider
|
<ListQueryContext.Provider
|
||||||
value={{
|
value={{
|
||||||
data,
|
data,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
@@ -193,6 +198,6 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Context.Provider>
|
</ListQueryContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
42
packages/ui/src/providers/ListQuery/types.ts
Normal file
42
packages/ui/src/providers/ListQuery/types.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type {
|
||||||
|
ColumnPreference,
|
||||||
|
ListPreferences,
|
||||||
|
ListQuery,
|
||||||
|
PaginatedDocs,
|
||||||
|
Sort,
|
||||||
|
Where,
|
||||||
|
} from 'payload'
|
||||||
|
|
||||||
|
type ContextHandlers = {
|
||||||
|
handlePageChange?: (page: number) => Promise<void>
|
||||||
|
handlePerPageChange?: (limit: number) => Promise<void>
|
||||||
|
handleSearchChange?: (search: string) => Promise<void>
|
||||||
|
handleSortChange?: (sort: string) => Promise<void>
|
||||||
|
handleWhereChange?: (where: Where) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnListQueryChange = (query: ListQuery) => void
|
||||||
|
|
||||||
|
export type ListQueryProps = {
|
||||||
|
readonly children: React.ReactNode
|
||||||
|
readonly collectionSlug?: string
|
||||||
|
readonly columns?: ColumnPreference[]
|
||||||
|
readonly data: PaginatedDocs
|
||||||
|
readonly defaultLimit?: number
|
||||||
|
readonly defaultSort?: Sort
|
||||||
|
readonly listPreferences?: ListPreferences
|
||||||
|
readonly modifySearchParams?: boolean
|
||||||
|
readonly onQueryChange?: OnListQueryChange
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
readonly preferenceKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IListQueryContext = {
|
||||||
|
data: PaginatedDocs
|
||||||
|
defaultLimit?: number
|
||||||
|
defaultSort?: Sort
|
||||||
|
query: ListQuery
|
||||||
|
refineListData: (args: ListQuery) => Promise<void>
|
||||||
|
} & ContextHandlers
|
||||||
@@ -49,9 +49,7 @@ export function DefaultListView(props: ListViewClientProps) {
|
|||||||
enableRowSelections,
|
enableRowSelections,
|
||||||
hasCreatePermission: hasCreatePermissionFromProps,
|
hasCreatePermission: hasCreatePermissionFromProps,
|
||||||
listMenuItems,
|
listMenuItems,
|
||||||
listPreferences,
|
|
||||||
newDocumentURL,
|
newDocumentURL,
|
||||||
preferenceKey,
|
|
||||||
renderedFilters,
|
renderedFilters,
|
||||||
resolvedFilterOptions,
|
resolvedFilterOptions,
|
||||||
Table: InitialTable,
|
Table: InitialTable,
|
||||||
@@ -153,15 +151,7 @@ export function DefaultListView(props: ListViewClientProps) {
|
|||||||
}, [setStepNav, labels, drawerDepth])
|
}, [setStepNav, labels, drawerDepth])
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<TableColumnsProvider
|
<TableColumnsProvider collectionSlug={collectionSlug} columnState={columnState}>
|
||||||
collectionSlug={collectionSlug}
|
|
||||||
columnState={columnState}
|
|
||||||
docs={docs}
|
|
||||||
enableRowSelections={enableRowSelections}
|
|
||||||
listPreferences={listPreferences}
|
|
||||||
preferenceKey={preferenceKey}
|
|
||||||
setTable={setTable}
|
|
||||||
>
|
|
||||||
<div className={`${baseClass} ${baseClass}--${collectionSlug}`}>
|
<div className={`${baseClass} ${baseClass}--${collectionSlug}`}>
|
||||||
<SelectionProvider docs={docs} totalDocs={data.totalDocs} user={user}>
|
<SelectionProvider docs={docs} totalDocs={data.totalDocs} user={user}>
|
||||||
{BeforeList}
|
{BeforeList}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Page } from '@playwright/test'
|
||||||
|
import type { User as PayloadUser } from 'payload'
|
||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { mapAsync } from 'payload'
|
import { mapAsync } from 'payload'
|
||||||
import * as qs from 'qs-esm'
|
import * as qs from 'qs-esm'
|
||||||
|
|
||||||
import type { Config, Geo, Post } from '../../payload-types.js'
|
import type { Config, Geo, Post, User } from '../../payload-types.js'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
exactText,
|
exactText,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
openDocDrawer,
|
|
||||||
} from '../../../helpers.js'
|
} from '../../../helpers.js'
|
||||||
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
|
||||||
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
|
||||||
@@ -31,11 +31,15 @@ const description = 'Description'
|
|||||||
|
|
||||||
let payload: PayloadTestSDK<Config>
|
let payload: PayloadTestSDK<Config>
|
||||||
|
|
||||||
|
import { devUser } from 'credentials.js'
|
||||||
import { addListFilter } from 'helpers/e2e/addListFilter.js'
|
import { addListFilter } from 'helpers/e2e/addListFilter.js'
|
||||||
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
|
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
|
||||||
import { openListColumns } from 'helpers/e2e/openListColumns.js'
|
import { openListColumns } from 'helpers/e2e/openListColumns.js'
|
||||||
import { openListFilters } from 'helpers/e2e/openListFilters.js'
|
import { openListFilters } from 'helpers/e2e/openListFilters.js'
|
||||||
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
|
import { deletePreferences } from 'helpers/e2e/preferences.js'
|
||||||
|
import { toggleColumn, waitForColumnInURL } from 'helpers/e2e/toggleColumn.js'
|
||||||
|
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
|
||||||
|
import { closeListDrawer } from 'helpers/e2e/toggleListDrawer.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { wait } from 'payload/shared'
|
import { wait } from 'payload/shared'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -58,6 +62,7 @@ describe('List View', () => {
|
|||||||
let customViewsUrl: AdminUrlUtil
|
let customViewsUrl: AdminUrlUtil
|
||||||
let with300DocumentsUrl: AdminUrlUtil
|
let with300DocumentsUrl: AdminUrlUtil
|
||||||
let withListViewUrl: AdminUrlUtil
|
let withListViewUrl: AdminUrlUtil
|
||||||
|
let user: any
|
||||||
|
|
||||||
let serverURL: string
|
let serverURL: string
|
||||||
let adminRoutes: ReturnType<typeof getRoutes>
|
let adminRoutes: ReturnType<typeof getRoutes>
|
||||||
@@ -87,6 +92,14 @@ describe('List View', () => {
|
|||||||
await ensureCompilationIsDone({ customAdminRoutes, page, serverURL })
|
await ensureCompilationIsDone({ customAdminRoutes, page, serverURL })
|
||||||
|
|
||||||
adminRoutes = getRoutes({ customAdminRoutes })
|
adminRoutes = getRoutes({ customAdminRoutes })
|
||||||
|
|
||||||
|
user = await payload.login({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -831,49 +844,91 @@ describe('List View', () => {
|
|||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should toggle columns', async () => {
|
test('should toggle columns and effect table', async () => {
|
||||||
const columnCountLocator = 'table > thead > tr > th'
|
const tableHeaders = 'table > thead > tr > th'
|
||||||
await createPost()
|
|
||||||
await openListColumns(page, {})
|
await openListColumns(page, {})
|
||||||
const numberOfColumns = await page.locator(columnCountLocator).count()
|
const numberOfColumns = await page.locator(tableHeaders).count()
|
||||||
await expect(page.locator('.column-selector')).toBeVisible()
|
await expect(page.locator('.column-selector')).toBeVisible()
|
||||||
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID')
|
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID')
|
||||||
await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' })
|
|
||||||
|
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
|
||||||
|
|
||||||
await page.locator('#heading-id').waitFor({ state: 'detached' })
|
await page.locator('#heading-id').waitFor({ state: 'detached' })
|
||||||
await page.locator('.cell-id').first().waitFor({ state: 'detached' })
|
await page.locator('.cell-id').first().waitFor({ state: 'detached' })
|
||||||
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1)
|
await expect(page.locator(tableHeaders)).toHaveCount(numberOfColumns - 1)
|
||||||
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number')
|
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number')
|
||||||
await toggleColumn(page, { columnLabel: 'ID', targetState: 'on' })
|
|
||||||
|
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'on' })
|
||||||
|
|
||||||
await expect(page.locator('.cell-id').first()).toBeVisible()
|
await expect(page.locator('.cell-id').first()).toBeVisible()
|
||||||
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns)
|
await expect(page.locator(tableHeaders)).toHaveCount(numberOfColumns)
|
||||||
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID')
|
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('ID')
|
||||||
|
|
||||||
|
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should toggle columns and save to preferences', async () => {
|
||||||
|
const tableHeaders = 'table > thead > tr > th'
|
||||||
|
const numberOfColumns = await page.locator(tableHeaders).count()
|
||||||
|
|
||||||
|
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
await expect(page.locator('#heading-id')).toBeHidden()
|
||||||
|
await expect(page.locator('.cell-id').first()).toBeHidden()
|
||||||
|
await expect(page.locator(tableHeaders)).toHaveCount(numberOfColumns - 1)
|
||||||
|
await expect(page.locator('table > thead > tr > th:nth-child(2)')).toHaveText('Number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should inject preferred columns into URL search params on load', async () => {
|
||||||
|
await toggleColumn(page, { columnLabel: 'ID', columnName: 'id', targetState: 'off' })
|
||||||
|
|
||||||
|
// reload to ensure the columns were stored and loaded from preferences
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
// The `columns` search params _should_ contain "-id"
|
||||||
|
await waitForColumnInURL({ page, columnName: 'id', state: 'off' })
|
||||||
|
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not inject default columns into URL search params on load', async () => {
|
||||||
|
// clear preferences first, ensure that they don't automatically populate in the URL on load
|
||||||
|
await deletePreferences({
|
||||||
|
payload,
|
||||||
|
key: `${postsCollectionSlug}.list`,
|
||||||
|
user,
|
||||||
|
})
|
||||||
|
|
||||||
|
// wait for the URL search params to populate
|
||||||
|
await page.waitForURL(/posts\?/)
|
||||||
|
|
||||||
|
// The `columns` search params should _not_ appear in the URL
|
||||||
|
expect(page.url()).not.toMatch(/columns=/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should drag to reorder columns and save to preferences', async () => {
|
test('should drag to reorder columns and save to preferences', async () => {
|
||||||
await createPost()
|
|
||||||
|
|
||||||
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
|
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
|
||||||
|
|
||||||
// reload to ensure the preferred order was stored in the database
|
// reload to ensure the columns were stored and loaded from preferences
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.list-controls .column-selector .column-selector__column').first(),
|
page.locator('.list-controls .column-selector .column-selector__column').first(),
|
||||||
).toHaveText('Number')
|
).toHaveText('Number')
|
||||||
|
|
||||||
await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number')
|
await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should render drawer columns in order', async () => {
|
test('should render list drawer columns in proper order', async () => {
|
||||||
// Re-order columns like done in the previous test
|
|
||||||
await createPost()
|
|
||||||
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
|
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
|
||||||
await createPost()
|
|
||||||
await page.goto(postsUrl.create)
|
await page.goto(postsUrl.create)
|
||||||
|
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
|
||||||
await openDocDrawer(page, '.rich-text .list-drawer__toggler')
|
|
||||||
|
|
||||||
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
||||||
await expect(listDrawer).toBeVisible()
|
await expect(listDrawer).toBeVisible()
|
||||||
|
|
||||||
@@ -883,17 +938,17 @@ describe('List View', () => {
|
|||||||
|
|
||||||
// select the "Post" collection
|
// select the "Post" collection
|
||||||
await collectionSelector.click()
|
await collectionSelector.click()
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
|
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
|
||||||
hasText: exactText('Post'),
|
hasText: exactText('Post'),
|
||||||
})
|
})
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
// open the column controls
|
await openListColumns(page, {
|
||||||
const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns')
|
columnContainerSelector: '.list-controls__columns',
|
||||||
await columnSelector.click()
|
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
|
||||||
// wait until the column toggle UI is visible and fully expanded
|
})
|
||||||
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible()
|
|
||||||
|
|
||||||
// ensure that the columns are in the correct order
|
// ensure that the columns are in the correct order
|
||||||
await expect(
|
await expect(
|
||||||
@@ -903,48 +958,94 @@ describe('List View', () => {
|
|||||||
).toHaveText('Number')
|
).toHaveText('Number')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should toggle columns in list drawer', async () => {
|
||||||
|
await page.goto(postsUrl.create)
|
||||||
|
|
||||||
|
// Open the drawer
|
||||||
|
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
|
||||||
|
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
||||||
|
await expect(listDrawer).toBeVisible()
|
||||||
|
|
||||||
|
await openListColumns(page, {
|
||||||
|
columnContainerSelector: '.list-controls__columns',
|
||||||
|
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
|
||||||
|
})
|
||||||
|
|
||||||
|
await toggleColumn(page, {
|
||||||
|
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
|
||||||
|
columnContainerSelector: '.list-controls__columns',
|
||||||
|
columnLabel: 'ID',
|
||||||
|
targetState: 'off',
|
||||||
|
expectURLChange: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await closeListDrawer({ page })
|
||||||
|
|
||||||
|
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
|
||||||
|
|
||||||
|
await openListColumns(page, {
|
||||||
|
columnContainerSelector: '.list-controls__columns',
|
||||||
|
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnContainer = page.locator('.list-controls__columns').first()
|
||||||
|
|
||||||
|
const column = columnContainer.locator(`.column-selector .column-selector__column`, {
|
||||||
|
hasText: exactText('ID'),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(column).not.toHaveClass(/column-selector__column--active/)
|
||||||
|
})
|
||||||
|
|
||||||
test('should retain preferences when changing drawer collections', async () => {
|
test('should retain preferences when changing drawer collections', async () => {
|
||||||
await page.goto(postsUrl.create)
|
await page.goto(postsUrl.create)
|
||||||
|
|
||||||
// Open the drawer
|
// Open the drawer
|
||||||
await openDocDrawer(page, '.rich-text .list-drawer__toggler')
|
await openDocDrawer({ page, selector: '.rich-text .list-drawer__toggler' })
|
||||||
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
||||||
await expect(listDrawer).toBeVisible()
|
await expect(listDrawer).toBeVisible()
|
||||||
|
|
||||||
|
await openListColumns(page, {
|
||||||
|
columnContainerSelector: '.list-controls__columns',
|
||||||
|
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
|
||||||
|
})
|
||||||
|
|
||||||
const collectionSelector = page.locator(
|
const collectionSelector = page.locator(
|
||||||
'[id^=list-drawer_1_] .list-header__select-collection.react-select',
|
'[id^=list-drawer_1_] .list-header__select-collection.react-select',
|
||||||
)
|
)
|
||||||
const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns')
|
|
||||||
|
|
||||||
// open the column controls
|
|
||||||
await columnSelector.click()
|
|
||||||
// wait until the column toggle UI is visible and fully expanded
|
// wait until the column toggle UI is visible and fully expanded
|
||||||
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible()
|
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible()
|
||||||
|
|
||||||
// deselect the "id" column
|
// deselect the "id" column
|
||||||
await page
|
await toggleColumn(page, {
|
||||||
.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', {
|
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
|
||||||
hasText: exactText('ID'),
|
columnContainerSelector: '.list-controls__columns',
|
||||||
})
|
columnLabel: 'ID',
|
||||||
.click()
|
targetState: 'off',
|
||||||
|
expectURLChange: false,
|
||||||
|
})
|
||||||
|
|
||||||
// select the "Post" collection
|
// select the "Post" collection
|
||||||
await collectionSelector.click()
|
await collectionSelector.click()
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
|
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
|
||||||
hasText: exactText('Post'),
|
hasText: exactText('Post'),
|
||||||
})
|
})
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
// deselect the "number" column
|
await toggleColumn(page, {
|
||||||
await page
|
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
|
||||||
.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', {
|
columnContainerSelector: '.list-controls__columns',
|
||||||
hasText: exactText('Number'),
|
columnLabel: 'Number',
|
||||||
})
|
targetState: 'off',
|
||||||
.click()
|
expectURLChange: false,
|
||||||
|
})
|
||||||
|
|
||||||
// select the "User" collection again
|
// select the "User" collection again
|
||||||
await collectionSelector.click()
|
await collectionSelector.click()
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
|
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
|
||||||
hasText: exactText('User'),
|
hasText: exactText('User'),
|
||||||
@@ -1139,7 +1240,9 @@ describe('List View', () => {
|
|||||||
|
|
||||||
test('should sort with existing filters', async () => {
|
test('should sort with existing filters', async () => {
|
||||||
await page.goto(postsUrl.list)
|
await page.goto(postsUrl.list)
|
||||||
await toggleColumn(page, { columnLabel: 'ID', targetState: 'off' })
|
|
||||||
|
await toggleColumn(page, { columnLabel: 'ID', targetState: 'off', columnName: 'id' })
|
||||||
|
|
||||||
await page.locator('#heading-id').waitFor({ state: 'detached' })
|
await page.locator('#heading-id').waitFor({ state: 'detached' })
|
||||||
await page.locator('#heading-title button.sort-column__asc').click()
|
await page.locator('#heading-title button.sort-column__asc').click()
|
||||||
await page.waitForURL(/sort=title/)
|
await page.waitForURL(/sort=title/)
|
||||||
@@ -1157,13 +1260,10 @@ describe('List View', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should sort without resetting column preferences', async () => {
|
test('should sort without resetting column preferences', async () => {
|
||||||
await payload.delete({
|
await deletePreferences({
|
||||||
collection: 'payload-preferences',
|
key: `${postsCollectionSlug}.list`,
|
||||||
where: {
|
payload,
|
||||||
key: {
|
user,
|
||||||
equals: `${postsCollectionSlug}.list`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(postsUrl.list)
|
await page.goto(postsUrl.list)
|
||||||
@@ -1173,7 +1273,8 @@ describe('List View', () => {
|
|||||||
await page.waitForURL(/sort=title/)
|
await page.waitForURL(/sort=title/)
|
||||||
|
|
||||||
// enable a column that is _not_ part of this collection's default columns
|
// enable a column that is _not_ part of this collection's default columns
|
||||||
await toggleColumn(page, { columnLabel: 'Status', targetState: 'on' })
|
await toggleColumn(page, { columnLabel: 'Status', targetState: 'on', columnName: '_status' })
|
||||||
|
|
||||||
await page.locator('#heading-_status').waitFor({ state: 'visible' })
|
await page.locator('#heading-_status').waitFor({ state: 'visible' })
|
||||||
|
|
||||||
const columnAfterSort = page.locator(
|
const columnAfterSort = page.locator(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Page } from '@playwright/test'
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { addListFilter } from 'helpers/e2e/addListFilter.js'
|
import { addListFilter } from 'helpers/e2e/addListFilter.js'
|
||||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||||
|
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { wait } from 'payload/shared'
|
import { wait } from 'payload/shared'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -19,13 +20,7 @@ import type {
|
|||||||
VersionedRelationshipField,
|
VersionedRelationshipField,
|
||||||
} from './payload-types.js'
|
} from './payload-types.js'
|
||||||
|
|
||||||
import {
|
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
|
||||||
ensureCompilationIsDone,
|
|
||||||
initPageConsoleErrorCatch,
|
|
||||||
openCreateDocDrawer,
|
|
||||||
openDocDrawer,
|
|
||||||
saveDocAndAssert,
|
|
||||||
} from '../helpers.js'
|
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
|
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
|
||||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
@@ -495,10 +490,11 @@ describe('Relationship Field', () => {
|
|||||||
const editURL = url.edit(docWithExistingRelations.id)
|
const editURL = url.edit(docWithExistingRelations.id)
|
||||||
await page.goto(editURL)
|
await page.goto(editURL)
|
||||||
|
|
||||||
await openDocDrawer(
|
await openDocDrawer({
|
||||||
page,
|
page,
|
||||||
'#field-relationshipReadOnly button.relationship--single-value__drawer-toggler.doc-drawer__toggler',
|
selector:
|
||||||
)
|
'#field-relationshipReadOnly button.relationship--single-value__drawer-toggler.doc-drawer__toggler',
|
||||||
|
})
|
||||||
|
|
||||||
const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]')
|
const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]')
|
||||||
await expect(documentDrawer).toBeVisible()
|
await expect(documentDrawer).toBeVisible()
|
||||||
@@ -506,7 +502,7 @@ describe('Relationship Field', () => {
|
|||||||
|
|
||||||
test('should open document drawer and append newly created docs onto the parent field', async () => {
|
test('should open document drawer and append newly created docs onto the parent field', async () => {
|
||||||
await page.goto(url.edit(docWithExistingRelations.id))
|
await page.goto(url.edit(docWithExistingRelations.id))
|
||||||
await openCreateDocDrawer(page, '#field-relationshipHasMany')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationshipHasMany' })
|
||||||
const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]')
|
const documentDrawer = page.locator('[id^=doc-drawer_relation-one_1_]')
|
||||||
await expect(documentDrawer).toBeVisible()
|
await expect(documentDrawer).toBeVisible()
|
||||||
const drawerField = documentDrawer.locator('#field-name')
|
const drawerField = documentDrawer.locator('#field-name')
|
||||||
@@ -532,10 +528,10 @@ describe('Relationship Field', () => {
|
|||||||
const saveButton = page.locator('#action-save')
|
const saveButton = page.locator('#action-save')
|
||||||
await expect(saveButton).toBeDisabled()
|
await expect(saveButton).toBeDisabled()
|
||||||
|
|
||||||
await openDocDrawer(
|
await openDocDrawer({
|
||||||
page,
|
page,
|
||||||
'#field-relationship button.relationship--single-value__drawer-toggler ',
|
selector: '#field-relationship button.relationship--single-value__drawer-toggler',
|
||||||
)
|
})
|
||||||
|
|
||||||
const field = page.locator('#field-name')
|
const field = page.locator('#field-name')
|
||||||
await field.fill('Updated')
|
await field.fill('Updated')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'
|
|||||||
import { addListFilter } from 'helpers/e2e/addListFilter.js'
|
import { addListFilter } from 'helpers/e2e/addListFilter.js'
|
||||||
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
||||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||||
import { openListFilters } from 'helpers/e2e/openListFilters.js'
|
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { wait } from 'payload/shared'
|
import { wait } from 'payload/shared'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -16,8 +16,6 @@ import {
|
|||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
exactText,
|
exactText,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
openCreateDocDrawer,
|
|
||||||
openDocDrawer,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
saveDocHotkeyAndAssert,
|
saveDocHotkeyAndAssert,
|
||||||
} from '../../../helpers.js'
|
} from '../../../helpers.js'
|
||||||
@@ -78,7 +76,7 @@ describe('relationship', () => {
|
|||||||
|
|
||||||
test('should create inline relationship within field with many relations', async () => {
|
test('should create inline relationship within field with many relations', async () => {
|
||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
await openCreateDocDrawer(page, '#field-relationship')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
|
||||||
await page
|
await page
|
||||||
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
|
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
|
||||||
.click()
|
.click()
|
||||||
@@ -100,7 +98,7 @@ describe('relationship', () => {
|
|||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
|
|
||||||
// Open first modal
|
// Open first modal
|
||||||
await openCreateDocDrawer(page, '#field-relationToSelf')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationToSelf' })
|
||||||
|
|
||||||
// Fill first modal's required relationship field
|
// Fill first modal's required relationship field
|
||||||
await page.locator('[id^=doc-drawer_relationship-fields_1_] #field-relationship').click()
|
await page.locator('[id^=doc-drawer_relationship-fields_1_] #field-relationship').click()
|
||||||
@@ -298,7 +296,7 @@ describe('relationship', () => {
|
|||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
|
|
||||||
// First fill out the relationship field, as it's required
|
// First fill out the relationship field, as it's required
|
||||||
await openCreateDocDrawer(page, '#field-relationship')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
|
||||||
await page
|
await page
|
||||||
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
|
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
|
||||||
.click()
|
.click()
|
||||||
@@ -313,7 +311,7 @@ describe('relationship', () => {
|
|||||||
|
|
||||||
// Create a new doc for the `relationshipHasMany` field
|
// Create a new doc for the `relationshipHasMany` field
|
||||||
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
|
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
|
||||||
await openCreateDocDrawer(page, '#field-relationshipHasMany')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationshipHasMany' })
|
||||||
const value = 'Hello, world!'
|
const value = 'Hello, world!'
|
||||||
await page.locator('.drawer__content #field-text').fill(value)
|
await page.locator('.drawer__content #field-text').fill(value)
|
||||||
|
|
||||||
@@ -326,10 +324,10 @@ describe('relationship', () => {
|
|||||||
// Mimic real user behavior by typing into the field with spaces and backspaces
|
// Mimic real user behavior by typing into the field with spaces and backspaces
|
||||||
// Explicitly use both `down` and `type` to cover edge cases
|
// Explicitly use both `down` and `type` to cover edge cases
|
||||||
|
|
||||||
await openDocDrawer(
|
await openDocDrawer({
|
||||||
page,
|
page,
|
||||||
'#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler',
|
selector: '#field-relationshipHasMany button.relationship--multi-value-label__drawer-toggler',
|
||||||
)
|
})
|
||||||
|
|
||||||
await page.locator('[id^=doc-drawer_text-fields_1_] #field-text').click()
|
await page.locator('[id^=doc-drawer_text-fields_1_] #field-text').click()
|
||||||
await page.keyboard.down('1')
|
await page.keyboard.down('1')
|
||||||
@@ -365,7 +363,7 @@ describe('relationship', () => {
|
|||||||
test('should save using hotkey in document drawer', async () => {
|
test('should save using hotkey in document drawer', async () => {
|
||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
// First fill out the relationship field, as it's required
|
// First fill out the relationship field, as it's required
|
||||||
await openCreateDocDrawer(page, '#field-relationship')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
|
||||||
await page.locator('#field-relationship .value-container').click()
|
await page.locator('#field-relationship .value-container').click()
|
||||||
await wait(500)
|
await wait(500)
|
||||||
// Select "Seeded text document" relationship
|
// Select "Seeded text document" relationship
|
||||||
@@ -413,7 +411,10 @@ describe('relationship', () => {
|
|||||||
.locator('#field-relationship .relationship--single-value')
|
.locator('#field-relationship .relationship--single-value')
|
||||||
.textContent()
|
.textContent()
|
||||||
|
|
||||||
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
|
await openDocDrawer({
|
||||||
|
page,
|
||||||
|
selector: '#field-relationship .relationship--single-value__drawer-toggler',
|
||||||
|
})
|
||||||
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
||||||
const originalDrawerID = await drawer1Content.locator('.id-label').textContent()
|
const originalDrawerID = await drawer1Content.locator('.id-label').textContent()
|
||||||
await openDocControls(drawer1Content)
|
await openDocControls(drawer1Content)
|
||||||
@@ -469,7 +470,10 @@ describe('relationship', () => {
|
|||||||
}),
|
}),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|
||||||
await openDocDrawer(page, '#field-relationship .relationship--single-value__drawer-toggler')
|
await openDocDrawer({
|
||||||
|
page,
|
||||||
|
selector: '#field-relationship .relationship--single-value__drawer-toggler',
|
||||||
|
})
|
||||||
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
||||||
const originalID = await drawer1Content.locator('.id-label').textContent()
|
const originalID = await drawer1Content.locator('.id-label').textContent()
|
||||||
const originalText = 'Text'
|
const originalText = 'Text'
|
||||||
@@ -527,10 +531,10 @@ describe('relationship', () => {
|
|||||||
}),
|
}),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|
||||||
await openDocDrawer(
|
await openDocDrawer({
|
||||||
page,
|
page,
|
||||||
'#field-relationship button.relationship--single-value__drawer-toggler',
|
selector: '#field-relationship button.relationship--single-value__drawer-toggler',
|
||||||
)
|
})
|
||||||
|
|
||||||
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
const drawer1Content = page.locator('[id^=doc-drawer_text-fields_1_] .drawer__content')
|
||||||
const originalID = await drawer1Content.locator('.id-label').textContent()
|
const originalID = await drawer1Content.locator('.id-label').textContent()
|
||||||
@@ -573,7 +577,7 @@ describe('relationship', () => {
|
|||||||
test.skip('should bypass min rows validation when no rows present and field is not required', async () => {
|
test.skip('should bypass min rows validation when no rows present and field is not required', async () => {
|
||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
// First fill out the relationship field, as it's required
|
// First fill out the relationship field, as it's required
|
||||||
await openCreateDocDrawer(page, '#field-relationship')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
|
||||||
await page.locator('#field-relationship .value-container').click()
|
await page.locator('#field-relationship .value-container').click()
|
||||||
await page.getByText('Seeded text document', { exact: true }).click()
|
await page.getByText('Seeded text document', { exact: true }).click()
|
||||||
|
|
||||||
@@ -585,7 +589,7 @@ describe('relationship', () => {
|
|||||||
await page.goto(url.create)
|
await page.goto(url.create)
|
||||||
|
|
||||||
// First fill out the relationship field, as it's required
|
// First fill out the relationship field, as it's required
|
||||||
await openCreateDocDrawer(page, '#field-relationship')
|
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
|
||||||
await page.locator('#field-relationship .value-container').click()
|
await page.locator('#field-relationship .value-container').click()
|
||||||
await page.getByText('Seeded text document', { exact: true }).click()
|
await page.getByText('Seeded text document', { exact: true }).click()
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { GeneratedTypes } from 'helpers/sdk/types.js'
|
|||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { openListColumns } from 'helpers/e2e/openListColumns.js'
|
import { openListColumns } from 'helpers/e2e/openListColumns.js'
|
||||||
|
import { upsertPreferences } from 'helpers/e2e/preferences.js'
|
||||||
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
|
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
|
||||||
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ describe('Text', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should respect admin.disableListColumn despite preferences', async () => {
|
test('should respect admin.disableListColumn despite preferences', async () => {
|
||||||
await upsertPrefs<Config, GeneratedTypes<any>>({
|
await upsertPreferences<Config, GeneratedTypes<any>>({
|
||||||
payload,
|
payload,
|
||||||
user: client.user,
|
user: client.user,
|
||||||
key: 'text-fields-list',
|
key: 'text-fields-list',
|
||||||
@@ -198,6 +198,7 @@ describe('Text', () => {
|
|||||||
await toggleColumn(page, {
|
await toggleColumn(page, {
|
||||||
targetState: 'on',
|
targetState: 'on',
|
||||||
columnLabel: 'Text en',
|
columnLabel: 'Text en',
|
||||||
|
columnName: 'localizedText',
|
||||||
})
|
})
|
||||||
|
|
||||||
const textCell = page.locator('.row-1 .cell-i18nText')
|
const textCell = page.locator('.row-1 .cell-i18nText')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { wait } from 'payload/shared'
|
import { wait } from 'payload/shared'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -11,7 +12,6 @@ import type { Config } from '../../payload-types.js'
|
|||||||
import {
|
import {
|
||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
openDocDrawer,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
} from '../../../helpers.js'
|
} from '../../../helpers.js'
|
||||||
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
|
||||||
@@ -155,14 +155,16 @@ describe('Upload', () => {
|
|||||||
await wait(1000)
|
await wait(1000)
|
||||||
// Open the media drawer and create a png upload
|
// Open the media drawer and create a png upload
|
||||||
|
|
||||||
await openDocDrawer(page, '#field-media .upload__createNewToggler')
|
await openDocDrawer({ page, selector: '#field-media .upload__createNewToggler' })
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
|
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
|
||||||
.setInputFiles(path.resolve(dirname, './uploads/payload.png'))
|
.setInputFiles(path.resolve(dirname, './uploads/payload.png'))
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'),
|
page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'),
|
||||||
).toHaveValue('payload.png')
|
).toHaveValue('payload.png')
|
||||||
|
|
||||||
await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click()
|
await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click()
|
||||||
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
|
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
|
||||||
|
|
||||||
@@ -170,9 +172,11 @@ describe('Upload', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.locator('.field-type.upload .upload-relationship-details__filename a'),
|
page.locator('.field-type.upload .upload-relationship-details__filename a'),
|
||||||
).toHaveAttribute('href', '/api/uploads/file/payload-1.png')
|
).toHaveAttribute('href', '/api/uploads/file/payload-1.png')
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.field-type.upload .upload-relationship-details__filename a'),
|
page.locator('.field-type.upload .upload-relationship-details__filename a'),
|
||||||
).toContainText('payload-1.png')
|
).toContainText('payload-1.png')
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.field-type.upload .upload-relationship-details img'),
|
page.locator('.field-type.upload .upload-relationship-details img'),
|
||||||
).toHaveAttribute('src', '/api/uploads/file/payload-1.png')
|
).toHaveAttribute('src', '/api/uploads/file/payload-1.png')
|
||||||
@@ -184,7 +188,7 @@ describe('Upload', () => {
|
|||||||
await wait(1000)
|
await wait(1000)
|
||||||
// Open the media drawer and create a png upload
|
// Open the media drawer and create a png upload
|
||||||
|
|
||||||
await openDocDrawer(page, '#field-media .upload__createNewToggler')
|
await openDocDrawer({ page, selector: '#field-media .upload__createNewToggler' })
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
|
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
|
||||||
@@ -222,7 +226,7 @@ describe('Upload', () => {
|
|||||||
await uploadImage()
|
await uploadImage()
|
||||||
await wait(1000) // TODO: Fix this. Need to wait a bit until the form in the drawer mounted, otherwise values sometimes disappear. This is an issue for all drawers
|
await wait(1000) // TODO: Fix this. Need to wait a bit until the form in the drawer mounted, otherwise values sometimes disappear. This is an issue for all drawers
|
||||||
|
|
||||||
await openDocDrawer(page, '#field-media .upload__createNewToggler')
|
await openDocDrawer({ page, selector: '#field-media .upload__createNewToggler' })
|
||||||
|
|
||||||
await wait(1000)
|
await wait(1000)
|
||||||
|
|
||||||
@@ -240,7 +244,7 @@ describe('Upload', () => {
|
|||||||
test('should select using the list drawer and restrict mimetype based on filterOptions', async () => {
|
test('should select using the list drawer and restrict mimetype based on filterOptions', async () => {
|
||||||
await uploadImage()
|
await uploadImage()
|
||||||
|
|
||||||
await openDocDrawer(page, '.field-type.upload .upload__listToggler')
|
await openDocDrawer({ page, selector: '.field-type.upload .upload__listToggler' })
|
||||||
|
|
||||||
const jpgImages = page.locator('[id^=list-drawer_1_] .upload-gallery img[src$=".jpg"]')
|
const jpgImages = page.locator('[id^=list-drawer_1_] .upload-gallery img[src$=".jpg"]')
|
||||||
await expect
|
await expect
|
||||||
@@ -262,7 +266,7 @@ describe('Upload', () => {
|
|||||||
await wait(200)
|
await wait(200)
|
||||||
|
|
||||||
// open drawer
|
// open drawer
|
||||||
await openDocDrawer(page, '.field-type.upload .list-drawer__toggler')
|
await openDocDrawer({ page, selector: '.field-type.upload .list-drawer__toggler' })
|
||||||
// check title
|
// check title
|
||||||
await expect(page.locator('.list-drawer__header-text')).toContainText('Uploads 3')
|
await expect(page.locator('.list-drawer__header-text')).toContainText('Uploads 3')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
|
import type { PayloadTestSDK } from 'helpers/sdk/index.js'
|
||||||
import type { GeneratedTypes } from 'helpers/sdk/types.js'
|
import type { GeneratedTypes } from 'helpers/sdk/types.js'
|
||||||
import type { TypedUser } from 'payload'
|
import type { TypedUser, User } from 'payload'
|
||||||
|
|
||||||
export const upsertPrefs = async <
|
export const upsertPreferences = async <
|
||||||
TConfig extends GeneratedTypes<any>,
|
TConfig extends GeneratedTypes<any>,
|
||||||
TGeneratedTypes extends GeneratedTypes<any>,
|
TGeneratedTypes extends GeneratedTypes<any>,
|
||||||
>({
|
>({
|
||||||
@@ -71,3 +71,28 @@ export const upsertPrefs = async <
|
|||||||
console.error('Error upserting prefs', e)
|
console.error('Error upserting prefs', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deletePreferences = async <TConfig extends GeneratedTypes<any>>({
|
||||||
|
payload,
|
||||||
|
user,
|
||||||
|
key,
|
||||||
|
}: {
|
||||||
|
key: string
|
||||||
|
payload: PayloadTestSDK<TConfig>
|
||||||
|
user: User
|
||||||
|
}): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await payload.delete({
|
||||||
|
collection: 'payload-preferences',
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ key: { equals: key } },
|
||||||
|
{ 'user.value': { equals: user.id } },
|
||||||
|
{ 'user.relationTo': { equals: user.collection } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting prefs', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,13 @@ export const toggleColumn = async (
|
|||||||
columnContainerSelector,
|
columnContainerSelector,
|
||||||
columnLabel,
|
columnLabel,
|
||||||
targetState: targetStateFromArgs,
|
targetState: targetStateFromArgs,
|
||||||
|
columnName,
|
||||||
|
expectURLChange = true,
|
||||||
}: {
|
}: {
|
||||||
columnContainerSelector?: string
|
columnContainerSelector?: string
|
||||||
columnLabel: string
|
columnLabel: string
|
||||||
|
columnName?: string
|
||||||
|
expectURLChange?: boolean
|
||||||
targetState?: 'off' | 'on'
|
targetState?: 'off' | 'on'
|
||||||
togglerSelector?: string
|
togglerSelector?: string
|
||||||
},
|
},
|
||||||
@@ -34,10 +38,10 @@ export const toggleColumn = async (
|
|||||||
|
|
||||||
await expect(column).toBeVisible()
|
await expect(column).toBeVisible()
|
||||||
|
|
||||||
if (
|
const requiresToggle =
|
||||||
(isActiveBeforeClick && targetState === 'off') ||
|
(isActiveBeforeClick && targetState === 'off') || (!isActiveBeforeClick && targetState === 'on')
|
||||||
(!isActiveBeforeClick && targetState === 'on')
|
|
||||||
) {
|
if (requiresToggle) {
|
||||||
await column.click()
|
await column.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,5 +53,30 @@ export const toggleColumn = async (
|
|||||||
await expect(column).toHaveClass(/column-selector__column--active/)
|
await expect(column).toHaveClass(/column-selector__column--active/)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expectURLChange && columnName && requiresToggle) {
|
||||||
|
await waitForColumnInURL({ page, columnName, state: targetState })
|
||||||
|
}
|
||||||
|
|
||||||
return column
|
return column
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const waitForColumnInURL = async ({
|
||||||
|
page,
|
||||||
|
columnName,
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
columnName: string
|
||||||
|
page: Page
|
||||||
|
state: 'off' | 'on'
|
||||||
|
}): Promise<void> => {
|
||||||
|
await page.waitForURL(/.*\?.*/)
|
||||||
|
|
||||||
|
const identifier = `${state === 'off' ? '-' : ''}${columnName}`
|
||||||
|
|
||||||
|
// Test that the identifier is in the URL
|
||||||
|
// It must appear in the `columns` query parameter, i.e. after `columns=...` and before the next `&`
|
||||||
|
// It must also appear in it entirety to prevent partially matching other values, i.e. between quotation marks
|
||||||
|
const regex = new RegExp(`columns=([^&]*${encodeURIComponent(`"${identifier}"`)}[^&]*)`)
|
||||||
|
|
||||||
|
await page.waitForURL(regex)
|
||||||
|
}
|
||||||
|
|||||||
32
test/helpers/e2e/toggleDocDrawer.ts
Normal file
32
test/helpers/e2e/toggleDocDrawer.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
import { wait } from 'payload/shared'
|
||||||
|
|
||||||
|
export async function openDocDrawer({
|
||||||
|
page,
|
||||||
|
selector,
|
||||||
|
}: {
|
||||||
|
page: Page
|
||||||
|
selector: string
|
||||||
|
}): Promise<void> {
|
||||||
|
await wait(500) // wait for parent form state to initialize
|
||||||
|
await page.locator(selector).click()
|
||||||
|
await wait(500) // wait for drawer form state to initialize
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openCreateDocDrawer({
|
||||||
|
page,
|
||||||
|
fieldSelector,
|
||||||
|
}: {
|
||||||
|
fieldSelector: string
|
||||||
|
page: Page
|
||||||
|
}): Promise<void> {
|
||||||
|
await wait(500) // wait for parent form state to initialize
|
||||||
|
const relationshipField = page.locator(fieldSelector)
|
||||||
|
await expect(relationshipField.locator('input')).toBeEnabled()
|
||||||
|
const addNewButton = relationshipField.locator('.relationship-add-new__add-button')
|
||||||
|
await expect(addNewButton).toBeVisible()
|
||||||
|
await addNewButton.click()
|
||||||
|
await wait(500) // wait for drawer form state to initialize
|
||||||
|
}
|
||||||
14
test/helpers/e2e/toggleListDrawer.ts
Normal file
14
test/helpers/e2e/toggleListDrawer.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
export const closeListDrawer = async ({
|
||||||
|
page,
|
||||||
|
drawerSelector = '[id^=list-drawer_1_]',
|
||||||
|
}: {
|
||||||
|
drawerSelector?: string
|
||||||
|
page: Page
|
||||||
|
}): Promise<any> => {
|
||||||
|
await page.locator('[id^=list-drawer_1_] .list-drawer__header-close').click()
|
||||||
|
await expect(page.locator(drawerSelector)).not.toBeVisible()
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import type { GeneratedTypes } from 'helpers/sdk/types.js'
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
|
||||||
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
import { openDocControls } from 'helpers/e2e/openDocControls.js'
|
||||||
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
|
import { upsertPreferences } from 'helpers/e2e/preferences.js'
|
||||||
|
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
|
||||||
import { RESTClient } from 'helpers/rest.js'
|
import { RESTClient } from 'helpers/rest.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
closeLocaleSelector,
|
closeLocaleSelector,
|
||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
openDocDrawer,
|
|
||||||
openLocaleSelector,
|
openLocaleSelector,
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
throttleTest,
|
throttleTest,
|
||||||
@@ -318,7 +318,7 @@ describe('Localization', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not render default locale in locale selector when prefs are not default', async () => {
|
test('should not render default locale in locale selector when prefs are not default', async () => {
|
||||||
await upsertPrefs<Config, GeneratedTypes<any>>({
|
await upsertPreferences<Config, GeneratedTypes<any>>({
|
||||||
payload,
|
payload,
|
||||||
user: client.user,
|
user: client.user,
|
||||||
key: 'locale',
|
key: 'locale',
|
||||||
@@ -349,7 +349,7 @@ describe('Localization', () => {
|
|||||||
const drawerToggler =
|
const drawerToggler =
|
||||||
'#field-relationMultiRelationTo .relationship--single-value__drawer-toggler'
|
'#field-relationMultiRelationTo .relationship--single-value__drawer-toggler'
|
||||||
await expect(page.locator(drawerToggler)).toBeEnabled()
|
await expect(page.locator(drawerToggler)).toBeEnabled()
|
||||||
await openDocDrawer(page, drawerToggler)
|
await openDocDrawer({ page, selector: drawerToggler })
|
||||||
await expect(page.locator('.doc-drawer__header-text')).toContainText('spanish-relation2')
|
await expect(page.locator('.doc-drawer__header-text')).toContainText('spanish-relation2')
|
||||||
await page.locator('.doc-drawer__header-close').click()
|
await page.locator('.doc-drawer__header-close').click()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface Config {
|
|||||||
auth: {
|
auth: {
|
||||||
users: UserAuthOperations;
|
users: UserAuthOperations;
|
||||||
};
|
};
|
||||||
|
blocks: {};
|
||||||
collections: {
|
collections: {
|
||||||
richText: RichText;
|
richText: RichText;
|
||||||
'blocks-fields': BlocksField;
|
'blocks-fields': BlocksField;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { wait } from 'payload/shared'
|
import { wait } from 'payload/shared'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
exactText,
|
exactText,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
openDocDrawer,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
@@ -381,7 +381,7 @@ describe('Uploads', () => {
|
|||||||
await page.locator('#field-versionedImage .icon--x').click()
|
await page.locator('#field-versionedImage .icon--x').click()
|
||||||
|
|
||||||
// choose from existing
|
// choose from existing
|
||||||
await openDocDrawer(page, '#field-versionedImage .upload__listToggler')
|
await openDocDrawer({ page, selector: '#field-versionedImage .upload__listToggler' })
|
||||||
|
|
||||||
await expect(page.locator('.row-3 .cell-title')).toContainText('draft')
|
await expect(page.locator('.row-3 .cell-title')).toContainText('draft')
|
||||||
})
|
})
|
||||||
@@ -402,12 +402,15 @@ describe('Uploads', () => {
|
|||||||
await wait(500) // flake workaround
|
await wait(500) // flake workaround
|
||||||
await page.locator('#field-audio .upload-relationship-details__remove').click()
|
await page.locator('#field-audio .upload-relationship-details__remove').click()
|
||||||
|
|
||||||
await openDocDrawer(page, '#field-audio .upload__listToggler')
|
await openDocDrawer({ page, selector: '#field-audio .upload__listToggler' })
|
||||||
|
|
||||||
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
||||||
await expect(listDrawer).toBeVisible()
|
await expect(listDrawer).toBeVisible()
|
||||||
|
|
||||||
await openDocDrawer(page, 'button.list-drawer__create-new-button.doc-drawer__toggler')
|
await openDocDrawer({
|
||||||
|
page,
|
||||||
|
selector: 'button.list-drawer__create-new-button.doc-drawer__toggler',
|
||||||
|
})
|
||||||
await expect(page.locator('[id^=doc-drawer_media_1_]')).toBeVisible()
|
await expect(page.locator('[id^=doc-drawer_media_1_]')).toBeVisible()
|
||||||
|
|
||||||
// upload an image and try to select it
|
// upload an image and try to select it
|
||||||
@@ -444,7 +447,7 @@ describe('Uploads', () => {
|
|||||||
await wait(500) // flake workaround
|
await wait(500) // flake workaround
|
||||||
await page.locator('#field-audio .upload-relationship-details__remove').click()
|
await page.locator('#field-audio .upload-relationship-details__remove').click()
|
||||||
|
|
||||||
await openDocDrawer(page, '.upload__listToggler')
|
await openDocDrawer({ page, selector: '.upload__listToggler' })
|
||||||
|
|
||||||
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
const listDrawer = page.locator('[id^=list-drawer_1_]')
|
||||||
await expect(listDrawer).toBeVisible()
|
await expect(listDrawer).toBeVisible()
|
||||||
|
|||||||
@@ -861,7 +861,7 @@ describe('Versions', () => {
|
|||||||
const publishOptions = page.locator('.doc-controls__controls .popup')
|
const publishOptions = page.locator('.doc-controls__controls .popup')
|
||||||
await publishOptions.click()
|
await publishOptions.click()
|
||||||
|
|
||||||
const publishSpecificLocale = page.locator('.popup-button-list button').first()
|
const publishSpecificLocale = page.locator('#publish-locale')
|
||||||
await expect(publishSpecificLocale).toContainText('English')
|
await expect(publishSpecificLocale).toContainText('English')
|
||||||
await publishSpecificLocale.click()
|
await publishSpecificLocale.click()
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@payload-config": ["./test/access-control/config.ts"],
|
"@payload-config": ["./test/admin/config.ts"],
|
||||||
"@payloadcms/live-preview": ["./packages/live-preview/src"],
|
"@payloadcms/live-preview": ["./packages/live-preview/src"],
|
||||||
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
||||||
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
|
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user