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:
Jacob Fletcher
2025-02-27 20:00:40 -05:00
committed by GitHub
parent 6ce5e8b83b
commit 3709950d50
34 changed files with 723 additions and 521 deletions

View File

@@ -1,18 +1,18 @@
import type {
AdminViewServerProps,
ListPreferences,
ListQuery,
ListViewClientProps,
ListViewServerPropsOnly,
Where,
} from 'payload'
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc'
import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
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 { renderListViewSlots } from './renderListViewSlots.js'
@@ -72,10 +72,20 @@ export const renderListView = async (
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>({
key: `${collectionSlug}-list`,
req,
value: {
columns,
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
sort: query?.sort as string,
},
@@ -141,6 +151,7 @@ export const renderListView = async (
clientCollectionConfig,
collectionConfig,
columnPreferences: listPreferences?.columns,
columns,
customCellProps,
docs: data.docs,
drawerSlug,
@@ -203,9 +214,11 @@ export const renderListView = async (
<Fragment>
<HydrateAuthProvider permissions={permissions} />
<ListQueryProvider
columns={transformColumnsToPreferences(columnState)}
data={data}
defaultLimit={limit}
defaultSort={sort}
listPreferences={listPreferences}
modifySearchParams={!isInDrawer}
>
{RenderServerComponent({

View File

@@ -1,8 +1,9 @@
import type { ImportMap } from '../../bin/generateImportMap/index.js'
import type { SanitizedConfig } from '../../config/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 { ColumnsFromURL } from '../../utilities/transformColumnPreferences.js'
export type DefaultServerFunctionArgs = {
importMap: ImportMap
@@ -38,6 +39,11 @@ export type ServerFunctionHandler = (
) => Promise<unknown>
export type ListQuery = {
/*
* This is an of strings, i.e. `['title', '-slug']`
* Use `transformColumnsToPreferences` to convert it back and forth
*/
columns?: ColumnsFromURL
limit?: string
page?: string
/*
@@ -50,7 +56,7 @@ export type ListQuery = {
export type BuildTableStateArgs = {
collectionSlug: string | string[]
columns?: { accessor: string; active: boolean }[]
columns?: ColumnPreference[]
docs?: PaginatedDocs['docs']
enableRowSelections?: boolean
parent?: {

View File

@@ -42,8 +42,14 @@ export type ListViewClientProps = {
disableBulkEdit?: boolean
enableRowSelections?: boolean
hasCreatePermission: boolean
/**
* @deprecated
*/
listPreferences?: ListPreferences
newDocumentURL: string
/**
* @deprecated
*/
preferenceKey?: string
renderedFilters?: Map<string, React.ReactNode>
resolvedFilterOptions?: Map<string, ResolvedFilterOptions>

View File

@@ -52,6 +52,7 @@ export {
deepCopyObjectSimple,
deepCopyObjectSimpleWithoutReactComponents,
} from '../utilities/deepCopyObject.js'
export {
deepMerge,
deepMergeWithCombinedArrays,
@@ -60,13 +61,13 @@ export {
} from '../utilities/deepMerge.js'
export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js'
export { flattenAllFields } from '../utilities/flattenAllFields.js'
export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js'
export { getDataByPath } from '../utilities/getDataByPath.js'
export { getSelectMode } from '../utilities/getSelectMode.js'
export { getSiblingData } from '../utilities/getSiblingData.js'
export { getUniqueListBy } from '../utilities/getUniqueListBy.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 {
transformColumnsToPreferences,
transformColumnsToSearchParams,
} from '../utilities/transformColumnPreferences.js'
export { unflatten } from '../utilities/unflatten.js'
export { validateMimeType } from '../utilities/validateMimeType.js'
export { wait } from '../utilities/wait.js'

View File

@@ -1374,6 +1374,7 @@ export { restoreVersionOperation as restoreVersionOperationGlobal } from './glob
export { updateOperation as updateOperationGlobal } from './globals/operations/update.js'
export type {
CollapsedPreferences,
ColumnPreference,
DocumentPreferences,
FieldsPreferences,
InsideFieldsPreferences,
@@ -1411,8 +1412,8 @@ export { getLocalI18n } from './translations/getLocalI18n.js'
export * from './types/index.js'
export { getFileByPath } from './uploads/getFileByPath.js'
export type * from './uploads/types.js'
export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js'
export { commitTransaction } from './utilities/commitTransaction.js'
export {

View File

@@ -28,8 +28,13 @@ export type DocumentPreferences = {
fields: FieldsPreferences
}
export type ColumnPreference = {
accessor: string
active: boolean
}
export type ListPreferences = {
columns?: { accessor: string; active: boolean }[]
columns?: ColumnPreference[]
limit?: number
sort?: string
}

View 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}`))
}

View File

@@ -1,16 +1,16 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import { useSearchParams } from 'next/navigation.js'
import { useRouter } from 'next/navigation.js'
import * as qs from 'qs-esm'
import React, { Fragment } from 'react'
import { useConfig } from '../../providers/Config/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 { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { Popup, PopupList } from '../Popup/index.js'
import { LocalizerLabel } from './LocalizerLabel/index.js'
import './index.scss'
import { LocalizerLabel } from './LocalizerLabel/index.js'
const baseClass = 'localizer'
@@ -21,7 +21,10 @@ export const Localizer: React.FC<{
const {
config: { localization },
} = useConfig()
const searchParams = useSearchParams()
const router = useRouter()
const { startRouteTransition } = useRouteTransition()
const { setLocaleIsLoading } = useLocaleLoading()
const { i18n } = useTranslation()
@@ -44,17 +47,28 @@ export const Localizer: React.FC<{
<PopupList.Button
active={locale.code === localeOption.code}
disabled={locale.code === localeOption.code}
href={qs.stringify(
{
...parseSearchParams(searchParams),
locale: localeOption.code,
},
{ addQueryPrefix: true },
)}
key={localeOption.code}
onClick={() => {
setLocaleIsLoading(true)
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 ? (

View File

@@ -222,7 +222,7 @@ export function PublishButton({ label: labelProp }: PublishButtonClientProps) {
)}
{localization && canPublish && (
<PopupList.ButtonGroup>
<PopupList.Button onClick={secondaryPublish}>
<PopupList.Button id="publish-locale" onClick={secondaryPublish}>
{secondaryLabel}
</PopupList.Button>
</PopupList.ButtonGroup>

View File

@@ -1,14 +1,14 @@
'use client'
import type {
CollectionSlug,
Column,
JoinFieldClient,
ListQuery,
PaginatedDocs,
Where,
} from 'payload'
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 type { DocumentDrawerProps } from '../DocumentDrawer/types.js'
@@ -137,7 +137,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
Table: NewTable,
} = await getTableState({
collectionSlug: relationTo,
columns: defaultColumns,
columns: transformColumnsToPreferences(query?.columns) || defaultColumns,
docs,
enableRowSelections: false,
parent,
@@ -154,7 +154,6 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
[
field.defaultLimit,
field.defaultSort,
field.admin.defaultColumns,
collectionConfig?.admin?.pagination?.defaultLimit,
collectionConfig?.defaultSort,
query,
@@ -215,8 +214,6 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
[data?.docs, renderTable],
)
const preferenceKey = `${Array.isArray(relationTo) ? `${parent.collectionSlug}-${parent.joinPath}` : relationTo}-list`
const canCreate =
allowCreate !== false &&
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 && (
<RelationshipProvider>
<ListQueryProvider
columns={transformColumnsToPreferences(columnState)}
data={data}
defaultLimit={
field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit
@@ -336,17 +334,9 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
<TableColumnsProvider
collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}
columnState={columnState}
docs={data.docs}
LinkedCellOverride={
<DrawerLink onDrawerDelete={onDrawerDelete} onDrawerSave={onDrawerSave} />
}
preferenceKey={preferenceKey}
renderRowTypes
setTable={setTable}
sortColumnProps={{
appearance: 'condensed',
}}
tableAppearance="condensed"
>
<AnimateHeight
className={`${baseClass}__columns`}

View 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)

View File

@@ -1,293 +1,98 @@
'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 { SortColumnProps } from '../SortColumn/index.js'
import type { TableColumnsProviderProps } from './types.js'
import { useConfig } from '../../providers/Config/index.js'
import { usePreferences } from '../../providers/Preferences/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { TableColumnContext } from './context.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 { useTableColumns } from './context.js'
export const TableColumnContext = createContext<ITableColumns>({} as ITableColumns)
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> = ({
export const TableColumnsProvider: React.FC<TableColumnsProviderProps> = ({
children,
collectionSlug,
columnState,
docs,
enableRowSelections,
columnState: columnStateFromProps,
LinkedCellOverride,
listPreferences,
preferenceKey,
renderRowTypes,
setTable,
sortColumnProps,
tableAppearance,
}) => {
const { getEntityConfig } = useConfig()
const { query: currentQuery, refineListData } = useListQuery()
const { getTableState } = useServerFunctions()
const { admin: { defaultColumns, useAsTitle } = {}, fields } = getEntityConfig({
const { admin: { defaultColumns } = {} } = getEntityConfig({
collectionSlug,
})
const prevCollection = React.useRef<SanitizedCollectionConfig['slug']>(
Array.isArray(collectionSlug) ? collectionSlug[0] : collectionSlug,
)
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 [columnState, setOptimisticColumnState] = React.useOptimistic(
columnStateFromProps,
(state, action: Column[]) => action,
)
const toggleColumn = useCallback(
async (column: string) => {
const controller = handleAbortRef(abortToggleColumnRef)
const { newColumnState, toggledColumns } = tableColumns.reduce<{
newColumnState: Column[]
toggledColumns: Pick<Column, 'accessor' | 'active'>[]
}>(
(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,
const newColumnState = (columnState || []).map((col) => {
if (col.accessor === column) {
return { ...col, active: !col.active }
}
return col
})
if (result) {
setTableColumns(result.state)
setTable(result.Table)
}
startTransition(() => {
setOptimisticColumnState(newColumnState)
})
abortToggleColumnRef.current = null
await refineListData({
columns: transformColumnsToSearchParams(newColumnState),
})
},
[
tableColumns,
getTableState,
setTable,
collectionSlug,
docs,
enableRowSelections,
renderRowTypes,
tableAppearance,
],
[refineListData, columnState, setOptimisticColumnState],
)
const setActiveColumns = React.useCallback(
async (activeColumnAccessors: string[]) => {
const activeColumns: Pick<Column, 'accessor' | 'active'>[] = tableColumns
.map((col) => {
return {
accessor: col.accessor,
active: activeColumnAccessors.includes(col.accessor),
}
})
.sort((first, second) => {
const indexOfFirst = activeColumnAccessors.indexOf(first.accessor)
const indexOfSecond = activeColumnAccessors.indexOf(second.accessor)
const moveColumn = useCallback(
async (args: { fromIndex: number; toIndex: number }) => {
const { fromIndex, toIndex } = args
const newColumnState = [...(columnState || [])]
const [columnToMove] = newColumnState.splice(fromIndex, 1)
newColumnState.splice(toIndex, 0, columnToMove)
if (indexOfFirst === -1 || indexOfSecond === -1) {
return 0
}
return indexOfFirst > indexOfSecond ? 1 : -1
})
const { state: columnState, Table } = await getTableState({
collectionSlug,
columns: activeColumns,
docs,
enableRowSelections,
renderRowTypes,
tableAppearance,
startTransition(() => {
setOptimisticColumnState(newColumnState)
})
setTableColumns(columnState)
setTable(Table)
await refineListData({
columns: transformColumnsToSearchParams(newColumnState),
})
},
[
tableColumns,
getTableState,
setTable,
collectionSlug,
docs,
enableRowSelections,
renderRowTypes,
tableAppearance,
],
[columnState, refineListData, setOptimisticColumnState],
)
const setActiveColumns = useCallback(
async (columns: string[]) => {
const newColumnState = currentQuery.columns
columns.forEach((colName) => {
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 () => {
await setActiveColumns(defaultColumns)
}, [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 (
<TableColumnContext.Provider
value={{
columns: tableColumns,
columns: columnState,
LinkedCellOverride,
moveColumn,
resetColumnsState,

View 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'
}

View File

@@ -1,7 +1,7 @@
'use client'
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 = {
readonly addCondition: AddCondition
@@ -11,7 +11,7 @@ export type Props = {
readonly operator: Operator
readonly orIndex: number
readonly reducedFields: ReducedField[]
readonly removeCondition: ({ andIndex, orIndex }: { andIndex: number; orIndex: number }) => void
readonly removeCondition: RemoveCondition
readonly RenderedFilter: React.ReactNode
readonly updateCondition: UpdateCondition
readonly value: string
@@ -67,9 +67,9 @@ export const Condition: React.FC<Props> = (props) => {
valueOptions = reducedField.field.options
}
const updateValue = useEffectEvent((debouncedValue) => {
const updateValue = useEffectEvent(async (debouncedValue) => {
if (operator) {
updateCondition({
await updateCondition({
andIndex,
field: reducedField,
operator,
@@ -80,7 +80,7 @@ export const Condition: React.FC<Props> = (props) => {
})
useEffect(() => {
updateValue(debouncedValue)
void updateValue(debouncedValue)
}, [debouncedValue])
const disabled =
@@ -88,9 +88,9 @@ export const Condition: React.FC<Props> = (props) => {
reducedField?.field?.admin?.disableListFilter
const handleFieldChange = useCallback(
(field: Option<string>) => {
async (field: Option<string>) => {
setInternalValue(undefined)
updateCondition({
await updateCondition({
andIndex,
field: reducedFields.find((option) => option.value === field.value),
operator,
@@ -102,8 +102,8 @@ export const Condition: React.FC<Props> = (props) => {
)
const handleOperatorChange = useCallback(
(operator: Option<Operator>) => {
updateCondition({
async (operator: Option<Operator>) => {
await updateCondition({
andIndex,
field: reducedField,
operator: operator.value,

View File

@@ -4,18 +4,17 @@ import type { Operator, Where } from 'payload'
import { getTranslation } from '@payloadcms/translations'
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 { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { Condition } from './Condition/index.js'
import fieldTypes from './field-types.js'
import { reduceFields } from './reduceFields.js'
import './index.scss'
import { transformWhereQuery } from './transformWhereQuery.js'
import validateWhereQuery from './validateWhereQuery.js'
import './index.scss'
const baseClass = 'where-builder'
@@ -32,7 +31,6 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
const reducedFields = useMemo(() => reduceFields({ fields, i18n }), [fields, i18n])
const { handleWhereChange, query } = useListQuery()
const [shouldUpdateQuery, setShouldUpdateQuery] = React.useState(false)
const [conditions, setConditions] = React.useState<Where[]>(() => {
const whereFromSearch = query.where
@@ -56,7 +54,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
})
const addCondition: AddCondition = React.useCallback(
({ andIndex, field, orIndex, relation }) => {
async ({ andIndex, field, orIndex, relation }) => {
const newConditions = [...conditions]
const defaultOperator = fieldTypes[field.field.type].operators[0].value
@@ -80,12 +78,13 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
}
setConditions(newConditions)
await handleWhereChange({ or: conditions })
},
[conditions],
[conditions, handleWhereChange],
)
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 defaults = fieldTypes[field.field.type]
@@ -102,14 +101,14 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
newConditions[orIndex].and[andIndex] = newRowCondition
setConditions(newConditions)
setShouldUpdateQuery(true)
await handleWhereChange({ or: conditions })
}
},
[conditions],
[conditions, handleWhereChange],
)
const removeCondition = React.useCallback(
({ andIndex, orIndex }) => {
const removeCondition: RemoveCondition = React.useCallback(
async ({ andIndex, orIndex }) => {
const newConditions = [...conditions]
newConditions[orIndex].and.splice(andIndex, 1)
@@ -118,21 +117,10 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
}
setConditions(newConditions)
setShouldUpdateQuery(true)
},
[conditions],
)
const handleChange = useEffectEvent(async (conditions: Where[]) => {
if (shouldUpdateQuery) {
await handleWhereChange({ or: conditions })
setShouldUpdateQuery(false)
}
})
React.useEffect(() => {
void handleChange(conditions)
}, [conditions])
},
[conditions, handleWhereChange],
)
return (
<div className={baseClass}>
@@ -191,8 +179,8 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
icon="plus"
iconPosition="left"
iconStyle="with-border"
onClick={() => {
addCondition({
onClick={async () => {
await addCondition({
andIndex: 0,
field: reducedFields[0],
orIndex: conditions.length,
@@ -213,9 +201,9 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
icon="plus"
iconPosition="left"
iconStyle="with-border"
onClick={() => {
onClick={async () => {
if (reducedFields.length > 0) {
addCondition({
await addCondition({
andIndex: 0,
field: reducedFields.find((field) => !field.field.admin?.disableListFilter),
orIndex: conditions.length,

View File

@@ -65,7 +65,7 @@ export type AddCondition = ({
field: ReducedField
orIndex: number
relation: 'and' | 'or'
}) => void
}) => Promise<void> | void
export type UpdateCondition = ({
andIndex,
@@ -79,4 +79,12 @@ export type UpdateCondition = ({
operator: string
orIndex: number
value: string
}) => void
}) => Promise<void> | void
export type RemoveCondition = ({
andIndex,
orIndex,
}: {
andIndex: number
orIndex: number
}) => Promise<void> | void

View 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)

View File

@@ -1,57 +1,39 @@
'use client'
import type { ListQuery, PaginatedDocs, Sort, Where } from 'payload'
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 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 { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { ListQueryContext } from './context.js'
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 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 { useListQuery } from './context.js'
export const ListQueryProvider: React.FC<ListQueryProps> = ({
children,
columns,
data,
defaultLimit,
defaultSort,
listPreferences,
modifySearchParams,
onQueryChange: onQueryChangeFromProps,
}) => {
'use no memo'
const router = useRouter()
const rawSearchParams = useSearchParams()
const searchParams = useMemo(() => parseSearchParams(rawSearchParams), [rawSearchParams])
const { startRouteTransition } = useRouteTransition()
const searchParams = useMemo<ListQuery>(
() => parseSearchParams(rawSearchParams),
[rawSearchParams],
)
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
useEffect(() => {
if (modifySearchParams) {
@@ -82,6 +62,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
}
const newQuery: ListQuery = {
columns: 'columns' in query ? query.columns : currentQuery.columns,
limit: 'limit' in query ? query.limit : (currentQuery?.limit ?? String(defaultLimit)),
page,
search: 'search' in query ? query.search : currentQuery?.search,
@@ -90,7 +71,14 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
}
if (modifySearchParams) {
router.replace(`${qs.stringify(newQuery, { addQueryPrefix: true })}`)
startRouteTransition(() =>
router.replace(
`${qs.stringify(
{ ...newQuery, columns: JSON.stringify(newQuery.columns) },
{ addQueryPrefix: true },
)}`,
),
)
} else if (
typeof onQueryChange === 'function' ||
typeof onQueryChangeFromProps === 'function'
@@ -102,11 +90,13 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
setCurrentQuery(newQuery)
},
[
currentQuery?.page,
currentQuery?.columns,
currentQuery?.limit,
currentQuery?.page,
currentQuery?.search,
currentQuery?.sort,
currentQuery?.where,
startRouteTransition,
defaultLimit,
defaultSort,
modifySearchParams,
@@ -152,35 +142,50 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
[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
// I.e. when HMR runs, these properties may be different
useEffect(() => {
if (modifySearchParams) {
let shouldUpdateQueryString = false
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)}`)
}
syncQuery()
}
}, [defaultSort, defaultLimit, router, modifySearchParams])
}, [defaultSort, defaultLimit, modifySearchParams, columns])
return (
<Context.Provider
<ListQueryContext.Provider
value={{
data,
handlePageChange,
@@ -193,6 +198,6 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
}}
>
{children}
</Context.Provider>
</ListQueryContext.Provider>
)
}

View 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

View File

@@ -49,9 +49,7 @@ export function DefaultListView(props: ListViewClientProps) {
enableRowSelections,
hasCreatePermission: hasCreatePermissionFromProps,
listMenuItems,
listPreferences,
newDocumentURL,
preferenceKey,
renderedFilters,
resolvedFilterOptions,
Table: InitialTable,
@@ -153,15 +151,7 @@ export function DefaultListView(props: ListViewClientProps) {
}, [setStepNav, labels, drawerDepth])
return (
<Fragment>
<TableColumnsProvider
collectionSlug={collectionSlug}
columnState={columnState}
docs={docs}
enableRowSelections={enableRowSelections}
listPreferences={listPreferences}
preferenceKey={preferenceKey}
setTable={setTable}
>
<TableColumnsProvider collectionSlug={collectionSlug} columnState={columnState}>
<div className={`${baseClass} ${baseClass}--${collectionSlug}`}>
<SelectionProvider docs={docs} totalDocs={data.totalDocs} user={user}>
{BeforeList}

View File

@@ -1,17 +1,17 @@
import type { Page } from '@playwright/test'
import type { User as PayloadUser } from 'payload'
import { expect, test } from '@playwright/test'
import { mapAsync } from 'payload'
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 {
ensureCompilationIsDone,
exactText,
getRoutes,
initPageConsoleErrorCatch,
openDocDrawer,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
@@ -31,11 +31,15 @@ const description = 'Description'
let payload: PayloadTestSDK<Config>
import { devUser } from 'credentials.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
import { openListColumns } from 'helpers/e2e/openListColumns.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 { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -58,6 +62,7 @@ describe('List View', () => {
let customViewsUrl: AdminUrlUtil
let with300DocumentsUrl: AdminUrlUtil
let withListViewUrl: AdminUrlUtil
let user: any
let serverURL: string
let adminRoutes: ReturnType<typeof getRoutes>
@@ -87,6 +92,14 @@ describe('List View', () => {
await ensureCompilationIsDone({ customAdminRoutes, page, serverURL })
adminRoutes = getRoutes({ customAdminRoutes })
user = await payload.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
})
beforeEach(async () => {
@@ -831,49 +844,91 @@ describe('List View', () => {
).toBeVisible()
})
test('should toggle columns', async () => {
const columnCountLocator = 'table > thead > tr > th'
await createPost()
test('should toggle columns and effect table', async () => {
const tableHeaders = 'table > thead > tr > th'
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('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('.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 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(columnCountLocator)).toHaveCount(numberOfColumns)
await expect(page.locator(tableHeaders)).toHaveCount(numberOfColumns)
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 () => {
await createPost()
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 expect(
page.locator('.list-controls .column-selector .column-selector__column').first(),
).toHaveText('Number')
await expect(page.locator('table thead tr th').nth(1)).toHaveText('Number')
})
test('should render drawer columns in order', async () => {
// Re-order columns like done in the previous test
await createPost()
test('should render list drawer columns in proper order', async () => {
await reorderColumns(page, { fromColumn: 'Number', toColumn: 'ID' })
await page.reload()
await createPost()
await page.goto(postsUrl.create)
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_]')
await expect(listDrawer).toBeVisible()
@@ -883,17 +938,17 @@ describe('List View', () => {
// select the "Post" collection
await collectionSelector.click()
await page
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
hasText: exactText('Post'),
})
.click()
// open the column controls
const columnSelector = page.locator('[id^=list-drawer_1_] .list-controls__toggle-columns')
await columnSelector.click()
// wait until the column toggle UI is visible and fully expanded
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible()
await openListColumns(page, {
columnContainerSelector: '.list-controls__columns',
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
})
// ensure that the columns are in the correct order
await expect(
@@ -903,48 +958,94 @@ describe('List View', () => {
).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 () => {
await page.goto(postsUrl.create)
// 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_]')
await expect(listDrawer).toBeVisible()
await openListColumns(page, {
columnContainerSelector: '.list-controls__columns',
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
})
const collectionSelector = page.locator(
'[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
await expect(page.locator('.list-controls__columns.rah-static--height-auto')).toBeVisible()
// deselect the "id" column
await page
.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', {
hasText: exactText('ID'),
})
.click()
await toggleColumn(page, {
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
columnContainerSelector: '.list-controls__columns',
columnLabel: 'ID',
targetState: 'off',
expectURLChange: false,
})
// select the "Post" collection
await collectionSelector.click()
await page
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
hasText: exactText('Post'),
})
.click()
// deselect the "number" column
await page
.locator('[id^=list-drawer_1_] .list-controls .column-selector .column-selector__column', {
hasText: exactText('Number'),
})
.click()
await toggleColumn(page, {
togglerSelector: '[id^=list-drawer_1_] .list-controls__toggle-columns',
columnContainerSelector: '.list-controls__columns',
columnLabel: 'Number',
targetState: 'off',
expectURLChange: false,
})
// select the "User" collection again
await collectionSelector.click()
await page
.locator('[id^=list-drawer_1_] .list-header__select-collection.react-select .rs__option', {
hasText: exactText('User'),
@@ -1139,7 +1240,9 @@ describe('List View', () => {
test('should sort with existing filters', async () => {
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-title button.sort-column__asc').click()
await page.waitForURL(/sort=title/)
@@ -1157,13 +1260,10 @@ describe('List View', () => {
})
test('should sort without resetting column preferences', async () => {
await payload.delete({
collection: 'payload-preferences',
where: {
key: {
equals: `${postsCollectionSlug}.list`,
},
},
await deletePreferences({
key: `${postsCollectionSlug}.list`,
payload,
user,
})
await page.goto(postsUrl.list)
@@ -1173,7 +1273,8 @@ describe('List View', () => {
await page.waitForURL(/sort=title/)
// 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' })
const columnAfterSort = page.locator(

View File

@@ -3,6 +3,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -19,13 +20,7 @@ import type {
VersionedRelationshipField,
} from './payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
openCreateDocDrawer,
openDocDrawer,
saveDocAndAssert,
} from '../helpers.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { trackNetworkRequests } from '../helpers/e2e/trackNetworkRequests.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
@@ -495,10 +490,11 @@ describe('Relationship Field', () => {
const editURL = url.edit(docWithExistingRelations.id)
await page.goto(editURL)
await openDocDrawer(
await openDocDrawer({
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_]')
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 () => {
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_]')
await expect(documentDrawer).toBeVisible()
const drawerField = documentDrawer.locator('#field-name')
@@ -532,10 +528,10 @@ describe('Relationship Field', () => {
const saveButton = page.locator('#action-save')
await expect(saveButton).toBeDisabled()
await openDocDrawer(
await openDocDrawer({
page,
'#field-relationship button.relationship--single-value__drawer-toggler ',
)
selector: '#field-relationship button.relationship--single-value__drawer-toggler',
})
const field = page.locator('#field-name')
await field.fill('Updated')

View File

@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.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 { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -16,8 +16,6 @@ import {
ensureCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
openCreateDocDrawer,
openDocDrawer,
saveDocAndAssert,
saveDocHotkeyAndAssert,
} from '../../../helpers.js'
@@ -78,7 +76,7 @@ describe('relationship', () => {
test('should create inline relationship within field with many relations', async () => {
await page.goto(url.create)
await openCreateDocDrawer(page, '#field-relationship')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationship' })
await page
.locator('#field-relationship .relationship-add-new__relation-button--text-fields')
.click()
@@ -100,7 +98,7 @@ describe('relationship', () => {
await page.goto(url.create)
// Open first modal
await openCreateDocDrawer(page, '#field-relationToSelf')
await openCreateDocDrawer({ page, fieldSelector: '#field-relationToSelf' })
// Fill first modal's required relationship field
await page.locator('[id^=doc-drawer_relationship-fields_1_] #field-relationship').click()
@@ -298,7 +296,7 @@ describe('relationship', () => {
await page.goto(url.create)
// 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 .relationship-add-new__relation-button--text-fields')
.click()
@@ -313,7 +311,7 @@ describe('relationship', () => {
// Create a new doc for the `relationshipHasMany` field
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!'
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
// Explicitly use both `down` and `type` to cover edge cases
await openDocDrawer(
await openDocDrawer({
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.keyboard.down('1')
@@ -365,7 +363,7 @@ describe('relationship', () => {
test('should save using hotkey in document drawer', async () => {
await page.goto(url.create)
// 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 wait(500)
// Select "Seeded text document" relationship
@@ -413,7 +411,10 @@ describe('relationship', () => {
.locator('#field-relationship .relationship--single-value')
.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 originalDrawerID = await drawer1Content.locator('.id-label').textContent()
await openDocControls(drawer1Content)
@@ -469,7 +470,10 @@ describe('relationship', () => {
}),
).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 originalID = await drawer1Content.locator('.id-label').textContent()
const originalText = 'Text'
@@ -527,10 +531,10 @@ describe('relationship', () => {
}),
).toBeVisible()
await openDocDrawer(
await openDocDrawer({
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 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 () => {
await page.goto(url.create)
// 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.getByText('Seeded text document', { exact: true }).click()
@@ -585,7 +589,7 @@ describe('relationship', () => {
await page.goto(url.create)
// 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.getByText('Seeded text document', { exact: true }).click()

View File

@@ -3,8 +3,8 @@ import type { GeneratedTypes } from 'helpers/sdk/types.js'
import { expect, test } from '@playwright/test'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { upsertPreferences } from 'helpers/e2e/preferences.js'
import { toggleColumn } from 'helpers/e2e/toggleColumn.js'
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -165,7 +165,7 @@ describe('Text', () => {
})
test('should respect admin.disableListColumn despite preferences', async () => {
await upsertPrefs<Config, GeneratedTypes<any>>({
await upsertPreferences<Config, GeneratedTypes<any>>({
payload,
user: client.user,
key: 'text-fields-list',
@@ -198,6 +198,7 @@ describe('Text', () => {
await toggleColumn(page, {
targetState: 'on',
columnLabel: 'Text en',
columnName: 'localizedText',
})
const textCell = page.locator('.row-1 .cell-i18nText')

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -11,7 +12,6 @@ import type { Config } from '../../payload-types.js'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
openDocDrawer,
saveDocAndAssert,
} from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
@@ -155,14 +155,16 @@ describe('Upload', () => {
await wait(1000)
// 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
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
.setInputFiles(path.resolve(dirname, './uploads/payload.png'))
await expect(
page.locator('[id^=doc-drawer_uploads_1_] .file-field__upload .file-field__filename'),
).toHaveValue('payload.png')
await page.locator('[id^=doc-drawer_uploads_1_] #action-save').click()
await expect(page.locator('.payload-toast-container')).toContainText('successfully')
@@ -170,9 +172,11 @@ describe('Upload', () => {
await expect(
page.locator('.field-type.upload .upload-relationship-details__filename a'),
).toHaveAttribute('href', '/api/uploads/file/payload-1.png')
await expect(
page.locator('.field-type.upload .upload-relationship-details__filename a'),
).toContainText('payload-1.png')
await expect(
page.locator('.field-type.upload .upload-relationship-details img'),
).toHaveAttribute('src', '/api/uploads/file/payload-1.png')
@@ -184,7 +188,7 @@ describe('Upload', () => {
await wait(1000)
// 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
.locator('[id^=doc-drawer_uploads_1_] .file-field__upload input[type="file"]')
@@ -222,7 +226,7 @@ describe('Upload', () => {
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 openDocDrawer(page, '#field-media .upload__createNewToggler')
await openDocDrawer({ page, selector: '#field-media .upload__createNewToggler' })
await wait(1000)
@@ -240,7 +244,7 @@ describe('Upload', () => {
test('should select using the list drawer and restrict mimetype based on filterOptions', async () => {
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"]')
await expect
@@ -262,7 +266,7 @@ describe('Upload', () => {
await wait(200)
// open drawer
await openDocDrawer(page, '.field-type.upload .list-drawer__toggler')
await openDocDrawer({ page, selector: '.field-type.upload .list-drawer__toggler' })
// check title
await expect(page.locator('.list-drawer__header-text')).toContainText('Uploads 3')
})

View File

@@ -1,8 +1,8 @@
import type { PayloadTestSDK } from 'helpers/sdk/index.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>,
TGeneratedTypes extends GeneratedTypes<any>,
>({
@@ -71,3 +71,28 @@ export const upsertPrefs = async <
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)
}
}

View File

@@ -12,9 +12,13 @@ export const toggleColumn = async (
columnContainerSelector,
columnLabel,
targetState: targetStateFromArgs,
columnName,
expectURLChange = true,
}: {
columnContainerSelector?: string
columnLabel: string
columnName?: string
expectURLChange?: boolean
targetState?: 'off' | 'on'
togglerSelector?: string
},
@@ -34,10 +38,10 @@ export const toggleColumn = async (
await expect(column).toBeVisible()
if (
(isActiveBeforeClick && targetState === 'off') ||
(!isActiveBeforeClick && targetState === 'on')
) {
const requiresToggle =
(isActiveBeforeClick && targetState === 'off') || (!isActiveBeforeClick && targetState === 'on')
if (requiresToggle) {
await column.click()
}
@@ -49,5 +53,30 @@ export const toggleColumn = async (
await expect(column).toHaveClass(/column-selector__column--active/)
}
if (expectURLChange && columnName && requiresToggle) {
await waitForColumnInURL({ page, columnName, state: targetState })
}
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)
}

View 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
}

View 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()
}

View File

@@ -4,7 +4,8 @@ import type { GeneratedTypes } from 'helpers/sdk/types.js'
import { expect, test } from '@playwright/test'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.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 path from 'path'
import { fileURLToPath } from 'url'
@@ -17,7 +18,6 @@ import {
closeLocaleSelector,
ensureCompilationIsDone,
initPageConsoleErrorCatch,
openDocDrawer,
openLocaleSelector,
saveDocAndAssert,
throttleTest,
@@ -318,7 +318,7 @@ describe('Localization', () => {
})
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,
user: client.user,
key: 'locale',
@@ -349,7 +349,7 @@ describe('Localization', () => {
const drawerToggler =
'#field-relationMultiRelationTo .relationship--single-value__drawer-toggler'
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 page.locator('.doc-drawer__header-close').click()
})

View File

@@ -64,6 +64,7 @@ export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
richText: RichText;
'blocks-fields': BlocksField;

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -12,7 +13,6 @@ import {
ensureCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
openDocDrawer,
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
@@ -381,7 +381,7 @@ describe('Uploads', () => {
await page.locator('#field-versionedImage .icon--x').click()
// 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')
})
@@ -402,12 +402,15 @@ describe('Uploads', () => {
await wait(500) // flake workaround
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_]')
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()
// upload an image and try to select it
@@ -444,7 +447,7 @@ describe('Uploads', () => {
await wait(500) // flake workaround
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_]')
await expect(listDrawer).toBeVisible()

View File

@@ -861,7 +861,7 @@ describe('Versions', () => {
const publishOptions = page.locator('.doc-controls__controls .popup')
await publishOptions.click()
const publishSpecificLocale = page.locator('.popup-button-list button').first()
const publishSpecificLocale = page.locator('#publish-locale')
await expect(publishSpecificLocale).toContainText('English')
await publishSpecificLocale.click()

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/access-control/config.ts"],
"@payload-config": ["./test/admin/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],