From 2a9946f788458fd1dd23230811a48a484c4c1360 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 15 Sep 2023 16:29:16 -0400 Subject: [PATCH 1/4] chore: resolves some of the testing suite issues --- package.json | 8 ++ packages/payload/package.json | 1 - .../src/collections/config/sanitize.ts | 10 ++- packages/payload/src/config/sanitize.ts | 3 +- .../src/fields/config/sanitize.spec.ts | 86 +++++++++++++++---- .../payload/src/fields/config/sanitize.ts | 36 ++++++-- .../payload/src/globals/config/sanitize.ts | 21 +++-- pnpm-lock.yaml | 28 ++++-- test/access-control/int.spec.ts | 7 +- test/auth/int.spec.ts | 3 +- test/collections-rest/int.spec.ts | 1 - test/helpers/configHelpers.ts | 1 + 12 files changed, 154 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 234577270b..52975c637b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:e2e": "ts-node -T ./test/runE2E.ts", "test:e2e:debug": "cross-env PWDEBUG=1 DISABLE_LOGGING=true playwright test", "test:e2e:headed": "cross-env DISABLE_LOGGING=true playwright test --headed", + "test:int:postgres": "cross-env PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true jest --forceExit --detectOpenHandles", "test:int": "cross-env DISABLE_LOGGING=true jest --forceExit --detectOpenHandles", "translateNewKeys": "pnpm --filter payload run translateNewKeys" }, @@ -49,9 +50,16 @@ "cross-env": "7.0.3", "express": "4.18.2", "get-port": "5.1.1", + "graphql-request": "3.7.0", + "isomorphic-fetch": "3.0.0", "jest": "29.6.4", "jest-environment-jsdom": "29.6.4", + "jwt-decode": "3.1.2", + "mongodb-memory-server": "8.13.0", + "node-fetch": "2.6.12", "prettier": "^3.0.3", + "qs": "6.11.2", + "react": "18.2.0", "shelljs": "0.8.5", "ts-node": "10.9.1", "typescript": "5.2.2", diff --git a/packages/payload/package.json b/packages/payload/package.json index 5c3d2babbf..1fa327bac5 100644 --- a/packages/payload/package.json +++ b/packages/payload/package.json @@ -213,7 +213,6 @@ "get-port": "5.1.1", "glob": "8.1.0", "graphql-request": "3.7.0", - "mongodb-memory-server": "8.13.0", "node-fetch": "2.6.12", "nodemon": "3.0.1", "object.assign": "4.1.4", diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index faf45a04cb..2b43fb587d 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -45,23 +45,23 @@ const sanitizeCollection = ( }) if (!hasUpdatedAt) { sanitized.fields.push({ + name: 'updatedAt', admin: { disableBulkEdit: true, hidden: true, }, label: translations['general:updatedAt'], - name: 'updatedAt', type: 'date', }) } if (!hasCreatedAt) { sanitized.fields.push({ + name: 'createdAt', admin: { disableBulkEdit: true, hidden: true, }, label: translations['general:createdAt'], - name: 'createdAt', type: 'date', }) } @@ -143,7 +143,11 @@ const sanitizeCollection = ( // ///////////////////////////////// const validRelationships = config.collections.map((c) => c.slug) - sanitized.fields = sanitizeFields(sanitized.fields, validRelationships) + sanitized.fields = sanitizeFields({ + config, + fields: sanitized.fields, + validRelationships, + }) return sanitized as SanitizedCollectionConfig } diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index 6a99d90fc9..792ed2b982 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -6,7 +6,6 @@ import type { LocalizationConfigWithLabels, LocalizationConfigWithNoLabels, SanitizedConfig, - SanitizedLocalizationConfig, } from './types' import { defaultUserCollection } from '../auth/defaultUser' @@ -96,7 +95,7 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => { checkDuplicateCollections(config.collections) if (config.globals.length > 0) { - config.globals = sanitizeGlobals(config.collections, config.globals) + config.globals = sanitizeGlobals(config as SanitizedConfig) } if (typeof config.serverURL === 'undefined') { diff --git a/packages/payload/src/fields/config/sanitize.spec.ts b/packages/payload/src/fields/config/sanitize.spec.ts index 9bf8b083e1..439c3a3088 100644 --- a/packages/payload/src/fields/config/sanitize.spec.ts +++ b/packages/payload/src/fields/config/sanitize.spec.ts @@ -7,9 +7,15 @@ import type { NumberField, TextField, } from './types' - +import { Config } from '../../config/types' import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors' import sanitizeFields from './sanitize' +import { DatabaseAdapter } from '../..' + +const dummyConfig: Config = { + collections: [], + db: () => ({}) as DatabaseAdapter, +} describe('sanitizeFields', () => { it('should throw on missing type field', () => { @@ -24,7 +30,11 @@ describe('sanitizeFields', () => { expect(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - sanitizeFields(fields, []) + sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + }) }).toThrow(MissingFieldType) }) it('should throw on invalid field name', () => { @@ -36,7 +46,11 @@ describe('sanitizeFields', () => { }, ] expect(() => { - sanitizeFields(fields, []) + sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + }) }).toThrow(InvalidFieldName) }) @@ -48,7 +62,11 @@ describe('sanitizeFields', () => { type: 'text', }, ] - const sanitizedField = sanitizeFields(fields, [])[0] as TextField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + })[0] as TextField expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual('Some Field') expect(sanitizedField.type).toStrictEqual('text') @@ -61,7 +79,11 @@ describe('sanitizeFields', () => { type: 'text', }, ] - const sanitizedField = sanitizeFields(fields, [])[0] as TextField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + })[0] as TextField expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual('Do not label') expect(sanitizedField.type).toStrictEqual('text') @@ -76,7 +98,11 @@ describe('sanitizeFields', () => { type: 'text', }, ] - const sanitizedField = sanitizeFields(fields, [])[0] as TextField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + })[0] as TextField expect(sanitizedField.name).toStrictEqual('someField') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('text') @@ -94,7 +120,11 @@ describe('sanitizeFields', () => { name: 'items', type: 'array', } - const sanitizedField = sanitizeFields([arrayField], [])[0] as ArrayField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields: [arrayField], + validRelationships: [], + })[0] as ArrayField expect(sanitizedField.name).toStrictEqual('items') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('array') @@ -119,7 +149,11 @@ describe('sanitizeFields', () => { type: 'blocks', }, ] - const sanitizedField = sanitizeFields(fields, [])[0] as BlockField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + })[0] as BlockField expect(sanitizedField.name).toStrictEqual('noLabelBlock') expect(sanitizedField.label).toStrictEqual(false) expect(sanitizedField.type).toStrictEqual('blocks') @@ -140,7 +174,11 @@ describe('sanitizeFields', () => { type: 'array', }, ] - const sanitizedField = sanitizeFields(fields, [])[0] as ArrayField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + })[0] as ArrayField expect(sanitizedField.name).toStrictEqual('items') expect(sanitizedField.label).toStrictEqual('Items') expect(sanitizedField.type).toStrictEqual('array') @@ -160,7 +198,11 @@ describe('sanitizeFields', () => { type: 'blocks', }, ] - const sanitizedField = sanitizeFields(fields, [])[0] as BlockField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + })[0] as BlockField expect(sanitizedField.name).toStrictEqual('specialBlock') expect(sanitizedField.label).toStrictEqual('Special Block') expect(sanitizedField.type).toStrictEqual('blocks') @@ -184,7 +226,7 @@ describe('sanitizeFields', () => { }, ] expect(() => { - sanitizeFields(fields, validRelationships) + sanitizeFields({ config: dummyConfig, fields, validRelationships }) }).not.toThrow() }) @@ -199,7 +241,7 @@ describe('sanitizeFields', () => { }, ] expect(() => { - sanitizeFields(fields, validRelationships) + sanitizeFields({ config: dummyConfig, fields, validRelationships }) }).not.toThrow() }) @@ -225,7 +267,7 @@ describe('sanitizeFields', () => { }, ] expect(() => { - sanitizeFields(fields, validRelationships) + sanitizeFields({ config: dummyConfig, fields, validRelationships }) }).not.toThrow() }) @@ -240,7 +282,7 @@ describe('sanitizeFields', () => { }, ] expect(() => { - sanitizeFields(fields, validRelationships) + sanitizeFields({ config: dummyConfig, fields, validRelationships }) }).toThrow(InvalidFieldRelationship) }) @@ -255,7 +297,7 @@ describe('sanitizeFields', () => { }, ] expect(() => { - sanitizeFields(fields, validRelationships) + sanitizeFields({ config: dummyConfig, fields, validRelationships }) }).toThrow(InvalidFieldRelationship) }) @@ -281,7 +323,7 @@ describe('sanitizeFields', () => { }, ] expect(() => { - sanitizeFields(fields, validRelationships) + sanitizeFields({ config: dummyConfig, fields, validRelationships }) }).toThrow(InvalidFieldRelationship) }) @@ -294,12 +336,20 @@ describe('sanitizeFields', () => { }, ] - const sanitizedField = sanitizeFields(fields, [])[0] as CheckboxField + const sanitizedField = sanitizeFields({ + config: dummyConfig, + fields, + validRelationships: [], + })[0] as CheckboxField expect(sanitizedField.defaultValue).toStrictEqual(false) }) it('should return empty field array if no fields', () => { - const sanitizedFields = sanitizeFields([], []) + const sanitizedFields = sanitizeFields({ + config: dummyConfig, + fields: [], + validRelationships: [], + }) expect(sanitizedFields).toStrictEqual([]) }) }) diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index fd6669adaf..1cc77506d5 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -1,3 +1,4 @@ +import type { Config } from '../../config/types' import type { Field } from './types' import withCondition from '../../admin/components/forms/withCondition' @@ -8,7 +9,13 @@ import { baseIDField } from '../baseFields/baseIDField' import validations from '../validations' import { fieldAffectsData, tabHasName } from './types' -const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] => { +type Args = { + config: Config + fields: Field[] + validRelationships: string[] +} + +const sanitizeFields = ({ config, fields, validRelationships }: Args): Field[] => { if (!fields) return [] return fields.map((unsanitizedField) => { @@ -80,6 +87,8 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] } if (fieldAffectsData(field)) { + if (field.localized && !config.localization) delete field.localized + if (typeof field.validate === 'undefined') { const defaultValidate = validations[field.type] if (defaultValidate) { @@ -101,8 +110,13 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] field.admin = {} } - if ('fields' in field && field.fields) - field.fields = sanitizeFields(field.fields, validRelationships) + if ('fields' in field && field.fields) { + field.fields = sanitizeFields({ + config, + fields: field.fields, + validRelationships, + }) + } if (field.type === 'tabs') { field.tabs = field.tabs.map((tab) => { @@ -110,7 +124,13 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] if (tabHasName(tab) && typeof tab.label === 'undefined') { unsanitizedTab.label = toWords(tab.name) } - unsanitizedTab.fields = sanitizeFields(tab.fields, validRelationships) + + unsanitizedTab.fields = sanitizeFields({ + config, + fields: tab.fields, + validRelationships, + }) + return unsanitizedTab }) } @@ -121,7 +141,13 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] unsanitizedBlock.labels = !unsanitizedBlock.labels ? formatLabels(unsanitizedBlock.slug) : unsanitizedBlock.labels - unsanitizedBlock.fields = sanitizeFields(block.fields, validRelationships) + + unsanitizedBlock.fields = sanitizeFields({ + config, + fields: block.fields, + validRelationships, + }) + return unsanitizedBlock }) } diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 6eaab3a732..2ae538940a 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -1,5 +1,5 @@ -import type { CollectionConfig } from '../../collections/config/types' -import type { GlobalConfig, SanitizedGlobalConfig } from './types' +import type { Config } from '../../config/types' +import type { SanitizedGlobalConfig } from './types' import defaultAccess from '../../auth/defaultAccess' import sanitizeFields from '../../fields/config/sanitize' @@ -9,10 +9,9 @@ import translations from '../../translations' import { toWords } from '../../utilities/formatLabels' import baseVersionFields from '../../versions/baseFields' -const sanitizeGlobals = ( - collections: CollectionConfig[], - globals: GlobalConfig[], -): SanitizedGlobalConfig[] => { +const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => { + const { collections, globals } = config + const sanitizedGlobals = globals.map((global) => { const sanitizedGlobal = { ...global } @@ -72,29 +71,33 @@ const sanitizeGlobals = ( }) if (!hasUpdatedAt) { sanitizedGlobal.fields.push({ + name: 'updatedAt', admin: { disableBulkEdit: true, hidden: true, }, label: translations['general:updatedAt'], - name: 'updatedAt', type: 'date', }) } if (!hasCreatedAt) { sanitizedGlobal.fields.push({ + name: 'createdAt', admin: { disableBulkEdit: true, hidden: true, }, label: translations['general:createdAt'], - name: 'createdAt', type: 'date', }) } const validRelationships = collections.map((c) => c.slug) - sanitizedGlobal.fields = sanitizeFields(sanitizedGlobal.fields, validRelationships) + sanitizedGlobal.fields = sanitizeFields({ + config, + fields: sanitizedGlobal.fields, + validRelationships, + }) return sanitizedGlobal as SanitizedGlobalConfig }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f51e2a2c38..ed152200d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,15 +54,36 @@ importers: get-port: specifier: 5.1.1 version: 5.1.1 + graphql-request: + specifier: 3.7.0 + version: 3.7.0(graphql@16.7.1) + isomorphic-fetch: + specifier: 3.0.0 + version: 3.0.0 jest: specifier: 29.6.4 version: 29.6.4(@types/node@20.5.7)(ts-node@10.9.1) jest-environment-jsdom: specifier: 29.6.4 version: 29.6.4 + jwt-decode: + specifier: 3.1.2 + version: 3.1.2 + mongodb-memory-server: + specifier: 8.13.0 + version: 8.13.0 + node-fetch: + specifier: 2.6.12 + version: 2.6.12 prettier: specifier: ^3.0.3 version: 3.0.3 + qs: + specifier: 6.11.2 + version: 6.11.2 + react: + specifier: 18.2.0 + version: 18.2.0 shelljs: specifier: 0.8.5 version: 0.8.5 @@ -696,9 +717,6 @@ importers: graphql-request: specifier: 3.7.0 version: 3.7.0(graphql@16.7.1) - mongodb-memory-server: - specifier: 8.13.0 - version: 8.13.0 node-fetch: specifier: 2.6.12 version: 2.6.12 @@ -9131,7 +9149,6 @@ packages: whatwg-fetch: 3.6.17 transitivePeerDependencies: - encoding - dev: false /issue-parser@6.0.0: resolution: {integrity: sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==} @@ -9853,7 +9870,6 @@ packages: /jwt-decode@3.1.2: resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==} - dev: false /kareem@2.5.1: resolution: {integrity: sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==} @@ -12253,7 +12269,6 @@ packages: engines: {node: '>=0.6'} dependencies: side-channel: 1.0.4 - dev: false /qs@6.4.1: resolution: {integrity: sha512-LQy1Q1fcva/UsnP/6Iaa4lVeM49WiOitu2T4hZCyA/elLKu37L99qcBJk4VCCk+rdLvnMzfKyiN3SZTqdAZGSQ==} @@ -14547,7 +14562,6 @@ packages: /whatwg-fetch@3.6.17: resolution: {integrity: sha512-c4ghIvG6th0eudYwKZY5keb81wtFz9/WeAHAoy8+r18kcWlitUIrmGFQ2rWEl4UCKUilD3zCLHOIPheHx5ypRQ==} - dev: false /whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index f2e0231fef..c9b7c58a32 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -4,17 +4,18 @@ import type { Post, RelyOnRequestHeader, Restricted } from './payload-types' import payload from '../../packages/payload/src' import { Forbidden } from '../../packages/payload/src/errors' import { initPayloadTest } from '../helpers/configHelpers' +import { requestHeaders } from './config' import { + firstArrayText, hiddenAccessSlug, hiddenFieldsSlug, relyOnRequestHeadersSlug, - requestHeaders, restrictedSlug, restrictedVersionsSlug, + secondArrayText, siblingDataSlug, slug, -} from './config' -import { firstArrayText, secondArrayText } from './shared' +} from './shared' describe('Access Control', () => { let post1: Post diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts index 22597be617..031b18e4ce 100644 --- a/test/auth/int.spec.ts +++ b/test/auth/int.spec.ts @@ -1,6 +1,5 @@ import { GraphQLClient } from 'graphql-request' import jwtDecode from 'jwt-decode' -import mongoose from 'mongoose' import type { User } from '../../packages/payload/src/auth' @@ -8,7 +7,7 @@ import payload from '../../packages/payload/src' import configPromise from '../collections-graphql/config' import { devUser } from '../credentials' import { initPayloadTest } from '../helpers/configHelpers' -import { namedSaveToJWTValue, saveToJWTKey, slug } from './config' +import { namedSaveToJWTValue, saveToJWTKey, slug } from './shared' require('isomorphic-fetch') diff --git a/test/collections-rest/int.spec.ts b/test/collections-rest/int.spec.ts index a713505ab9..fbb4dadee5 100644 --- a/test/collections-rest/int.spec.ts +++ b/test/collections-rest/int.spec.ts @@ -1,5 +1,4 @@ import { randomBytes } from 'crypto' -import mongoose from 'mongoose' import type { Relation } from './config' import type { ErrorOnHook, Post } from './payload-types' diff --git a/test/helpers/configHelpers.ts b/test/helpers/configHelpers.ts index cf01f0f12f..2607cd090c 100644 --- a/test/helpers/configHelpers.ts +++ b/test/helpers/configHelpers.ts @@ -34,6 +34,7 @@ export async function initPayloadTest(options: Options): Promise<{ serverURL: st ...(options.init || {}), } + process.env.PAYLOAD_DROP_DATABASE = 'true' process.env.NODE_ENV = 'test' process.env.PAYLOAD_CONFIG_PATH = path.resolve(options.__dirname, './config.ts') From 4b514d4c946e4c672b9a891c99e9a28c8e867ec4 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 15 Sep 2023 16:38:23 -0400 Subject: [PATCH 2/4] chore: establishes pattern for custom collection and global views (#3312) --- packages/payload/src/admin/Root.tsx | 2 +- .../payload/src/admin/components/Routes.tsx | 362 ------------------ .../admin/components/views/Global/index.tsx | 22 +- .../admin/components/views/Routes/child.tsx | 79 ++++ .../components/views/Routes/collections.tsx | 121 ++++++ .../admin/components/views/Routes/custom.tsx | 32 ++ .../admin/components/views/Routes/globals.tsx | 79 ++++ .../admin/components/views/Routes/index.tsx | 212 ++++++++++ .../components/views/Version/Version.tsx | 2 + .../components/views/Versions/Default.tsx | 160 ++++++++ .../admin/components/views/Versions/index.tsx | 207 ++++------ .../admin/components/views/Versions/types.ts | 15 +- .../views/collections/Edit/index.tsx | 33 +- .../payload/src/collections/config/schema.ts | 16 +- .../payload/src/collections/config/types.ts | 50 ++- packages/payload/src/config/schema.ts | 12 +- .../shared}/componentSchema.ts | 6 + .../payload/src/config/shared/routeSchema.ts | 13 + packages/payload/src/config/types.ts | 8 +- packages/payload/src/fields/config/schema.ts | 2 +- packages/payload/src/globals/config/schema.ts | 16 +- packages/payload/src/globals/config/types.ts | 50 ++- .../{views => routes}/CustomDefault/index.tsx | 23 +- .../CustomMinimal/index.scss | 0 .../{views => routes}/CustomMinimal/index.tsx | 0 .../components/views/CustomEdit/index.tsx | 98 +++++ .../components/views/CustomVersions/index.tsx | 85 ++++ .../components/views/CustomView/index.tsx | 67 ++++ test/admin/config.ts | 106 ++++- 29 files changed, 1333 insertions(+), 545 deletions(-) delete mode 100644 packages/payload/src/admin/components/Routes.tsx create mode 100644 packages/payload/src/admin/components/views/Routes/child.tsx create mode 100644 packages/payload/src/admin/components/views/Routes/collections.tsx create mode 100644 packages/payload/src/admin/components/views/Routes/custom.tsx create mode 100644 packages/payload/src/admin/components/views/Routes/globals.tsx create mode 100644 packages/payload/src/admin/components/views/Routes/index.tsx create mode 100644 packages/payload/src/admin/components/views/Versions/Default.tsx rename packages/payload/src/{utilities => config/shared}/componentSchema.ts (50%) create mode 100644 packages/payload/src/config/shared/routeSchema.ts rename test/admin/components/{views => routes}/CustomDefault/index.tsx (82%) rename test/admin/components/{views => routes}/CustomMinimal/index.scss (100%) rename test/admin/components/{views => routes}/CustomMinimal/index.tsx (100%) create mode 100644 test/admin/components/views/CustomEdit/index.tsx create mode 100644 test/admin/components/views/CustomVersions/index.tsx create mode 100644 test/admin/components/views/CustomView/index.tsx diff --git a/packages/payload/src/admin/Root.tsx b/packages/payload/src/admin/Root.tsx index fb657ba215..d97f309790 100644 --- a/packages/payload/src/admin/Root.tsx +++ b/packages/payload/src/admin/Root.tsx @@ -10,7 +10,6 @@ import React from 'react' import { BrowserRouter as Router } from 'react-router-dom' import { Slide, ToastContainer } from 'react-toastify' -import Routes from './components/Routes' import { StepNavProvider } from './components/elements/StepNav' import { AuthProvider } from './components/utilities/Auth' import { ConfigProvider } from './components/utilities/Config' @@ -21,6 +20,7 @@ import { LocaleProvider } from './components/utilities/Locale' import { PreferencesProvider } from './components/utilities/Preferences' import { SearchParamsProvider } from './components/utilities/SearchParams' import { ThemeProvider } from './components/utilities/Theme' +import { Routes } from './components/views/Routes' import './scss/app.scss' const Root = () => ( diff --git a/packages/payload/src/admin/components/Routes.tsx b/packages/payload/src/admin/components/Routes.tsx deleted file mode 100644 index bbab8c11e2..0000000000 --- a/packages/payload/src/admin/components/Routes.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import React, { Fragment, Suspense, lazy, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Redirect, Route, Switch } from 'react-router-dom' - -import { requests } from '../api' -import { LoadingOverlayToggle } from './elements/Loading' -import StayLoggedIn from './modals/StayLoggedIn' -import DefaultTemplate from './templates/Default' -import { useAuth } from './utilities/Auth' -import { useConfig } from './utilities/Config' -import { DocumentInfoProvider } from './utilities/DocumentInfo' -import { useLocale } from './utilities/Locale' -import Version from './views/Version' -import Versions from './views/Versions' -import List from './views/collections/List' - -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const Dashboard = lazy(() => import('./views/Dashboard')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const ForgotPassword = lazy(() => import('./views/ForgotPassword')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const Login = lazy(() => import('./views/Login')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const Logout = lazy(() => import('./views/Logout')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const NotFound = lazy(() => import('./views/NotFound')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const Verify = lazy(() => import('./views/Verify')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const CreateFirstUser = lazy(() => import('./views/CreateFirstUser')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const Edit = lazy(() => import('./views/collections/Edit')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const EditGlobal = lazy(() => import('./views/Global')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const ResetPassword = lazy(() => import('./views/ResetPassword')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const Unauthorized = lazy(() => import('./views/Unauthorized')) -// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue -const Account = lazy(() => import('./views/Account')) - -const Routes: React.FC = () => { - const [initialized, setInitialized] = useState(null) - const { permissions, refreshCookie, user } = useAuth() - const { i18n } = useTranslation() - const { code: locale } = useLocale() - - const canAccessAdmin = permissions?.canAccessAdmin - - const config = useConfig() - - const { - admin: { - components: { routes: customRoutes } = {}, - inactivityRoute: logoutInactivityRoute, - logoutRoute, - user: userSlug, - }, - collections, - globals, - routes, - } = config - - const isLoadingUser = Boolean( - typeof user === 'undefined' || (user && typeof canAccessAdmin === 'undefined'), - ) - const userCollection = collections.find(({ slug }) => slug === userSlug) - - useEffect(() => { - const { slug } = userCollection - - if (!userCollection.auth.disableLocalStrategy) { - requests - .get(`${routes.api}/${slug}/init`, { - headers: { - 'Accept-Language': i18n.language, - }, - }) - .then((res) => - res.json().then((data) => { - if (data && 'initialized' in data) { - setInitialized(data.initialized) - } - }), - ) - } else { - setInitialized(true) - } - }, [i18n.language, routes, userCollection]) - - return ( - }> - - { - if (initialized === false) { - return ( - - - - - - - - - ) - } - - if (initialized === true && !isLoadingUser) { - return ( - - {Array.isArray(customRoutes) && - customRoutes.map(({ Component, exact, path, sensitive, strict }) => ( - - - - ))} - - - - - - - - - - {!userCollection.auth.disableLocalStrategy && ( - - - - )} - - {!userCollection.auth.disableLocalStrategy && ( - - - - )} - - {collections.map((collection) => { - if (collection?.auth?.verify && !collection.auth.disableLocalStrategy) { - return ( - - - - ) - } - return null - })} - - - {user ? ( - - {canAccessAdmin && ( - - - - - - - slug === userSlug)} - id={user.id} - > - - - - {collections - .filter( - ({ admin: { hidden } }) => - !(typeof hidden === 'function' ? hidden({ user }) : hidden), - ) - .reduce((collectionRoutes, collection) => { - const routesToReturn = [ - ...collectionRoutes, - - {permissions?.collections?.[collection.slug]?.read - ?.permission ? ( - - ) : ( - - )} - , - - {permissions?.collections?.[collection.slug]?.create - ?.permission ? ( - - - - ) : ( - - )} - , - - {permissions?.collections?.[collection.slug]?.read - ?.permission ? ( - - - - ) : ( - - )} - , - ] - - if (collection.versions) { - routesToReturn.push( - - {permissions?.collections?.[collection.slug]?.readVersions - ?.permission ? ( - - ) : ( - - )} - , - ) - - routesToReturn.push( - - {permissions?.collections?.[collection.slug]?.readVersions - ?.permission ? ( - - - - ) : ( - - )} - , - ) - } - - return routesToReturn - }, [])} - {globals && - globals - .filter( - ({ admin: { hidden } }) => - !(typeof hidden === 'function' ? hidden({ user }) : hidden), - ) - .reduce((globalRoutes, global) => { - const routesToReturn = [ - ...globalRoutes, - - {permissions?.globals?.[global.slug]?.read?.permission ? ( - - - - ) : ( - - )} - , - ] - - if (global.versions) { - routesToReturn.push( - - {permissions?.globals?.[global.slug]?.readVersions - ?.permission ? ( - - ) : ( - - )} - , - ) - - routesToReturn.push( - - {permissions?.globals?.[global.slug]?.readVersions - ?.permission ? ( - - ) : ( - - )} - , - ) - } - - return routesToReturn - }, [])} - - - - - - - )} - {canAccessAdmin === false && } - - ) : ( - - )} - - - - - - ) - } - - return null - }} - /> - - - ) -} - -export default Routes diff --git a/packages/payload/src/admin/components/views/Global/index.tsx b/packages/payload/src/admin/components/views/Global/index.tsx index 00b490e412..e05772bc82 100644 --- a/packages/payload/src/admin/components/views/Global/index.tsx +++ b/packages/payload/src/admin/components/views/Global/index.tsx @@ -20,7 +20,7 @@ const GlobalView: React.FC = (props) => { const { state: locationState } = useLocation<{ data?: Record }>() const { code: locale } = useLocale() const { setStepNav } = useStepNav() - const { user } = useAuth() + const { permissions, user } = useAuth() const [initialState, setInitialState] = useState() const [updatedAt, setUpdatedAt] = useState() const { docPermissions, getDocPermissions, getDocPreferences, getVersions, preferencesKey } = @@ -36,12 +36,26 @@ const GlobalView: React.FC = (props) => { const { global } = props const { - admin: { components: { views: { Edit: CustomEdit } = {} } = {} } = {}, + admin: { components: { views: { Edit: Edit } = {} } = {} } = {}, fields, label, slug, } = global + // The component definition could come from multiple places in the config + // we need to cascade into the proper component from the top-down + // 1. "components.Edit" + // 2. "components.Edit.Default" + // 3. "components.Edit.Default.Component" + const CustomEditView = + typeof Edit === 'function' + ? Edit + : typeof Edit === 'object' && typeof Edit.Default === 'function' + ? Edit.Default + : typeof Edit?.Default === 'object' && typeof Edit.Default.Component === 'function' + ? Edit.Default.Component + : undefined + const onSave = useCallback( async (json) => { getVersions() @@ -102,13 +116,14 @@ const GlobalView: React.FC = (props) => { return ( = (props) => { onSave, permissions: docPermissions, updatedAt: updatedAt || dataToRender?.updatedAt, + user, }} /> ) diff --git a/packages/payload/src/admin/components/views/Routes/child.tsx b/packages/payload/src/admin/components/views/Routes/child.tsx new file mode 100644 index 0000000000..53653653ac --- /dev/null +++ b/packages/payload/src/admin/components/views/Routes/child.tsx @@ -0,0 +1,79 @@ +import type { match } from 'react-router-dom' + +import React from 'react' +import { Route } from 'react-router-dom' + +import type { Permissions, User } from '../../../../auth' +import type { SanitizedCollectionConfig, SanitizedGlobalConfig } from '../../../../exports/types' + +import Unauthorized from '../Unauthorized' + +export const childRoutes = (props: { + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig + match: match<{ + [key: string]: string | undefined + }> + permissions: Permissions + user: User +}): React.ReactElement[] => { + const { collection, global, match, permissions, user } = props + + let customViews = [] + const internalViews = ['Default', 'Versions'] + + const BaseEdit = + collection?.admin?.components?.views?.Edit || global?.admin?.components?.views?.Edit + + if (typeof BaseEdit !== 'function' && typeof BaseEdit === 'object') { + customViews = Object.entries(BaseEdit) + .filter(([viewKey, view]) => { + // Remove internal views from the list of custom views + // This way we can easily iterate over the remaining views + return Boolean( + !internalViews.includes(viewKey) && + typeof view !== 'function' && + typeof view === 'object', + ) + }) + ?.map(([, view]) => view) + } + + return customViews?.reduce((acc, { Component, path }) => { + const routesToReturn = [...acc] + + if (collection) { + routesToReturn.push( + + {permissions?.collections?.[collection.slug]?.read?.permission ? ( + + ) : ( + + )} + , + ) + } + + if (global) { + routesToReturn.push( + + {permissions?.globals?.[global.slug]?.read?.permission ? ( + + ) : ( + + )} + , + ) + } + + return routesToReturn + }, []) +} diff --git a/packages/payload/src/admin/components/views/Routes/collections.tsx b/packages/payload/src/admin/components/views/Routes/collections.tsx new file mode 100644 index 0000000000..fbc1da1a55 --- /dev/null +++ b/packages/payload/src/admin/components/views/Routes/collections.tsx @@ -0,0 +1,121 @@ +import type { match } from 'react-router-dom' + +import { lazy } from 'react' +import React from 'react' +import { Route } from 'react-router-dom' + +import type { Permissions, User } from '../../../../auth' +import type { SanitizedCollectionConfig } from '../../../../collections/config/types' + +import { DocumentInfoProvider } from '../../utilities/DocumentInfo' +import Version from '../Version' +import Versions from '../Versions' +import List from '../collections/List' +import { childRoutes } from './child' + +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Edit = lazy(() => import('../collections/Edit')) + +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Unauthorized = lazy(() => import('../Unauthorized')) + +export const collectionRoutes = (props: { + collections: SanitizedCollectionConfig[] + match: match<{ + [key: string]: string | undefined + }> + permissions: Permissions + user: User +}): React.ReactElement[] => { + const { collections, match, permissions, user } = props + + // Note: `Route` must be directly nested within `Switch` for `useRouteMatch` to work + // This means that we cannot use `Fragment` here with a simple map function to return an array of routes + // Instead, we need to use `reduce` to return an array of routes directly within `Switch` + return collections + ?.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden)) + .reduce((acc, collection) => { + // Default routes + const routesToReturn = [ + ...acc, + + {permissions?.collections?.[collection.slug]?.read?.permission ? ( + + ) : ( + + )} + , + + {permissions?.collections?.[collection.slug]?.create?.permission ? ( + + + + ) : ( + + )} + , + + {permissions?.collections?.[collection.slug]?.read?.permission ? ( + + + + ) : ( + + )} + , + childRoutes({ + collection, + match, + permissions, + user, + }), + ] + + // Version routes + if (collection.versions) { + routesToReturn.push( + + {permissions?.collections?.[collection.slug]?.readVersions?.permission ? ( + + ) : ( + + )} + , + ) + + routesToReturn.push( + + {permissions?.collections?.[collection.slug]?.readVersions?.permission ? ( + + + + ) : ( + + )} + , + ) + } + + return routesToReturn + }, []) +} diff --git a/packages/payload/src/admin/components/views/Routes/custom.tsx b/packages/payload/src/admin/components/views/Routes/custom.tsx new file mode 100644 index 0000000000..6c21ac9e31 --- /dev/null +++ b/packages/payload/src/admin/components/views/Routes/custom.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Route } from 'react-router-dom' + +import type { User } from '../../../../auth' +import type { SanitizedConfig } from '../../../../exports/config' + +export const customRoutes = (props: { + canAccessAdmin: boolean + customRoutes: SanitizedConfig['admin']['components']['routes'] + match: { url: string } + user: User +}) => { + const { canAccessAdmin, customRoutes, match, user } = props + + if (Array.isArray(customRoutes)) { + return customRoutes.map(({ Component, exact, path, sensitive, strict }) => ( + // You are responsible for ensuring that your own custom route is secure + // i.e. return `Unauthorized` in your own component if the user does not have permission + + + + )) + } + + return null +} diff --git a/packages/payload/src/admin/components/views/Routes/globals.tsx b/packages/payload/src/admin/components/views/Routes/globals.tsx new file mode 100644 index 0000000000..c52a72681e --- /dev/null +++ b/packages/payload/src/admin/components/views/Routes/globals.tsx @@ -0,0 +1,79 @@ +import type { match } from 'react-router-dom' + +import { lazy } from 'react' +import React from 'react' +import { Route } from 'react-router-dom' + +import type { Permissions, User } from '../../../../auth' +import type { SanitizedGlobalConfig } from '../../../../exports/types' + +import { DocumentInfoProvider } from '../../utilities/DocumentInfo' +import Version from '../Version' +import Versions from '../Versions' + +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const EditGlobal = lazy(() => import('../Global')) + +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Unauthorized = lazy(() => import('../Unauthorized')) + +export const globalRoutes = (props: { + globals: SanitizedGlobalConfig[] + locale: string + match: match<{ + [key: string]: string | undefined + }> + permissions: Permissions + user: User +}): React.ReactElement[] => { + const { globals, locale, match, permissions, user } = props + + // Note: `Route` must be directly nested within `Switch` for `useRouteMatch` to work + // This means that we cannot use `Fragment` here with a simple map function to return an array of routes + // Instead, we need to use `reduce` to return an array of routes directly within `Switch` + return globals + ?.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden)) + .reduce((acc, global) => { + const canReadGlobal = permissions?.globals?.[global.slug]?.read?.permission + const canReadVersions = permissions?.globals?.[global.slug]?.readVersions?.permission + + // Default routes + const routesToReturn = [ + ...acc, + + {canReadGlobal ? ( + + + + ) : ( + + )} + , + ] + + // Version routes + if (global.versions) { + routesToReturn.push( + + {canReadVersions ? : } + , + ) + + routesToReturn.push( + + {canReadVersions ? : } + , + ) + } + + return routesToReturn + }, []) +} diff --git a/packages/payload/src/admin/components/views/Routes/index.tsx b/packages/payload/src/admin/components/views/Routes/index.tsx new file mode 100644 index 0000000000..7960a919f2 --- /dev/null +++ b/packages/payload/src/admin/components/views/Routes/index.tsx @@ -0,0 +1,212 @@ +import React, { Fragment, Suspense, lazy, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Redirect, Route, Switch } from 'react-router-dom' + +import { requests } from '../../../api' +import { LoadingOverlayToggle } from '../../elements/Loading' +import StayLoggedIn from '../../modals/StayLoggedIn' +import DefaultTemplate from '../../templates/Default' +import { useAuth } from '../../utilities/Auth' +import { useConfig } from '../../utilities/Config' +import { DocumentInfoProvider } from '../../utilities/DocumentInfo' +import { useLocale } from '../../utilities/Locale' +import { collectionRoutes } from './collections' +import { customRoutes } from './custom' +import { globalRoutes } from './globals' + +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Dashboard = lazy(() => import('../Dashboard')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const ForgotPassword = lazy(() => import('../ForgotPassword')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Login = lazy(() => import('../Login')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Logout = lazy(() => import('../Logout')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const NotFound = lazy(() => import('../NotFound')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Verify = lazy(() => import('../Verify')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const CreateFirstUser = lazy(() => import('../CreateFirstUser')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const ResetPassword = lazy(() => import('../ResetPassword')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Unauthorized = lazy(() => import('../Unauthorized')) +// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue +const Account = lazy(() => import('../Account')) + +export const Routes: React.FC = () => { + const [initialized, setInitialized] = useState(null) + const { permissions, refreshCookie, user } = useAuth() + const { i18n } = useTranslation() + const { code: locale } = useLocale() + + const canAccessAdmin = permissions?.canAccessAdmin + + const config = useConfig() + + const { + admin: { + components: { routes: customRoutesConfig } = {}, + inactivityRoute: logoutInactivityRoute, + logoutRoute, + user: userSlug, + }, + collections, + globals, + routes, + } = config + + const isLoadingUser = Boolean( + typeof user === 'undefined' || (user && typeof canAccessAdmin === 'undefined'), + ) + + const userCollection = collections.find(({ slug }) => slug === userSlug) + + useEffect(() => { + const { slug } = userCollection + + if (!userCollection.auth.disableLocalStrategy) { + requests + .get(`${routes.api}/${slug}/init`, { + headers: { + 'Accept-Language': i18n.language, + }, + }) + .then((res) => + res.json().then((data) => { + if (data && 'initialized' in data) { + setInitialized(data.initialized) + } + }), + ) + } else { + setInitialized(true) + } + }, [i18n.language, routes, userCollection]) + + return ( + }> + + { + if (initialized === false) { + return ( + + + + + + + + + ) + } + + if (initialized === true && !isLoadingUser) { + return ( + + {customRoutes({ + canAccessAdmin, + customRoutes: customRoutesConfig, + match, + user, + })} + + + + + + + + + + {!userCollection.auth.disableLocalStrategy && ( + + + + )} + {!userCollection.auth.disableLocalStrategy && ( + + + + )} + {collections.map((collection) => { + if (collection?.auth?.verify && !collection.auth.disableLocalStrategy) { + return ( + + + + ) + } + return null + })} + + {user ? ( + + {canAccessAdmin && ( + + + + + + + slug === userSlug)} + id={user.id} + > + + + + {collectionRoutes({ + collections, + match, + permissions, + user, + })} + {globalRoutes({ + globals, + locale, + match, + permissions, + user, + })} + + + + + + )} + {canAccessAdmin === false && } + + ) : ( + + )} + + + + + + ) + } + + return null + }} + /> + + + ) +} diff --git a/packages/payload/src/admin/components/views/Version/Version.tsx b/packages/payload/src/admin/components/views/Version/Version.tsx index a346b4081e..81c64e6a11 100644 --- a/packages/payload/src/admin/components/views/Version/Version.tsx +++ b/packages/payload/src/admin/components/views/Version/Version.tsx @@ -37,9 +37,11 @@ const VersionView: React.FC = ({ collection, global }) => { serverURL, } = useConfig() const { setStepNav } = useStepNav() + const { params: { id, versionID }, } = useRouteMatch<{ id?: string; versionID: string }>() + const [compareValue, setCompareValue] = useState(mostRecentVersionOption) const [localeOptions] = useState(() => (localization ? localization.locales : [])) const [locales, setLocales] = useState(localeOptions) diff --git a/packages/payload/src/admin/components/views/Versions/Default.tsx b/packages/payload/src/admin/components/views/Versions/Default.tsx new file mode 100644 index 0000000000..6e88efb848 --- /dev/null +++ b/packages/payload/src/admin/components/views/Versions/Default.tsx @@ -0,0 +1,160 @@ +import React, { useEffect } from 'react' +import { useTranslation } from 'react-i18next' + +import type { StepNavItem } from '../../elements/StepNav/types' +import type { Props } from './types' + +import { getTranslation } from '../../../../utilities/getTranslation' +import Eyebrow from '../../elements/Eyebrow' +import { Gutter } from '../../elements/Gutter' +import IDLabel from '../../elements/IDLabel' +import { LoadingOverlayToggle } from '../../elements/Loading' +import Paginator from '../../elements/Paginator' +import PerPage from '../../elements/PerPage' +import { useStepNav } from '../../elements/StepNav' +import { Table } from '../../elements/Table' +import { useConfig } from '../../utilities/Config' +import Meta from '../../utilities/Meta' +import { useSearchParams } from '../../utilities/SearchParams' +import { buildVersionColumns } from './columns' +import './index.scss' + +const baseClass = 'versions' + +export const DefaultVersionsView: React.FC = (props) => { + const { collection, data, editURL, entityLabel, global, id, isLoadingVersions, versionsData } = + props + + const { + routes: { admin }, + } = useConfig() + + const { setStepNav } = useStepNav() + + const { i18n, t } = useTranslation('version') + + const { limit } = useSearchParams() + + const useAsTitle = collection?.admin?.useAsTitle || 'id' + + useEffect(() => { + let nav: StepNavItem[] = [] + + if (collection) { + let docLabel = '' + + if (data) { + if (useAsTitle) { + if (data[useAsTitle]) { + docLabel = data[useAsTitle] + } else { + docLabel = `[${t('general:untitled')}]` + } + } else { + docLabel = data.id + } + } + + nav = [ + { + label: getTranslation(collection.labels.plural, i18n), + url: `${admin}/collections/${collection.slug}`, + }, + { + label: docLabel, + url: editURL, + }, + { + label: t('versions'), + }, + ] + } + + if (global) { + nav = [ + { + label: getTranslation(global.label, i18n), + url: editURL, + }, + { + label: t('versions'), + }, + ] + } + + setStepNav(nav) + }, [setStepNav, collection, global, useAsTitle, data, admin, id, editURL, t, i18n]) + + let useIDLabel = data[useAsTitle] === data?.id + let heading: string + let metaDesc: string + let metaTitle: string + + if (collection) { + metaTitle = `${t('versions')} - ${data[useAsTitle]} - ${entityLabel}` + metaDesc = t('viewingVersions', { documentTitle: data[useAsTitle], entityLabel }) + heading = data?.[useAsTitle] || `[${t('general:untitled')}]` + } + + if (global) { + metaTitle = `${t('versions')} - ${entityLabel}` + metaDesc = t('viewingVersionsGlobal', { entityLabel }) + heading = entityLabel + useIDLabel = false + } + + return ( + + +
+ + + +
+
{t('showingVersionsFor')}
+ {useIDLabel && } + {!useIDLabel &&

{heading}

} +
+ {versionsData?.totalDocs > 0 && ( + + +
+ + {versionsData?.totalDocs > 0 && ( + +
+ {versionsData.page * versionsData.limit - (versionsData.limit - 1)}- + {versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page + ? versionsData.limit * versionsData.page + : versionsData.totalDocs}{' '} + {t('of')} {versionsData.totalDocs} +
+ +
+ )} +
+ + )} + {versionsData?.totalDocs === 0 && ( +
{t('noFurtherVersionsFound')}
+ )} + + + + ) +} diff --git a/packages/payload/src/admin/components/views/Versions/index.tsx b/packages/payload/src/admin/components/views/Versions/index.tsx index 23e8f1f061..1199accfe4 100644 --- a/packages/payload/src/admin/components/views/Versions/index.tsx +++ b/packages/payload/src/admin/components/views/Versions/index.tsx @@ -2,40 +2,38 @@ import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useRouteMatch } from 'react-router-dom' -import type { StepNavItem } from '../../elements/StepNav/types' -import type { Props } from './types' +import type { IndexProps } from './types' import { getTranslation } from '../../../../utilities/getTranslation' import usePayloadAPI from '../../../hooks/usePayloadAPI' -import Eyebrow from '../../elements/Eyebrow' -import { Gutter } from '../../elements/Gutter' -import IDLabel from '../../elements/IDLabel' -import { LoadingOverlayToggle } from '../../elements/Loading' -import Paginator from '../../elements/Paginator' -import PerPage from '../../elements/PerPage' -import { useStepNav } from '../../elements/StepNav' -import { Table } from '../../elements/Table' +import { useAuth } from '../../utilities/Auth' import { useConfig } from '../../utilities/Config' -import Meta from '../../utilities/Meta' +import { EditDepthContext } from '../../utilities/EditDepth' +import RenderCustomComponent from '../../utilities/RenderCustomComponent' import { useSearchParams } from '../../utilities/SearchParams' -import { buildVersionColumns } from './columns' -import './index.scss' +import { DefaultVersionsView } from './Default' -const baseClass = 'versions' +const VersionsView: React.FC = (props) => { + const { collection, global } = props + + const { permissions, user } = useAuth() + + const [fetchURL, setFetchURL] = useState('') -const Versions: React.FC = ({ collection, global }) => { const { routes: { admin, api }, serverURL, } = useConfig() - const { setStepNav } = useStepNav() + + const { i18n } = useTranslation('version') + + const { limit, page, sort } = useSearchParams() + const { params: { id }, } = useRouteMatch<{ id: string }>() - const { i18n, t } = useTranslation('version') - const [fetchURL, setFetchURL] = useState('') - const { limit, page, sort } = useSearchParams() + let CustomVersionsView: React.ComponentType | null = null let docURL: string let entityLabel: string let slug: string @@ -46,6 +44,21 @@ const Versions: React.FC = ({ collection, global }) => { docURL = `${serverURL}${api}/${slug}/${id}` entityLabel = getTranslation(collection.labels.singular, i18n) editURL = `${admin}/collections/${collection.slug}/${id}` + + // The component definition could come from multiple places in the config + // we need to cascade into the proper component from the top-down + // 1. "components.Edit" + // 2. "components.Edit.Versions" + // 3. "components.Edit.Versions.Component" + const Edit = collection?.admin?.components?.views?.Edit + CustomVersionsView = + typeof Edit === 'function' + ? Edit + : typeof Edit === 'object' && typeof Edit.Versions === 'function' + ? Edit.Versions + : typeof Edit?.Versions === 'object' && typeof Edit.Versions.Component === 'function' + ? Edit.Versions.Component + : undefined } if (global) { @@ -53,61 +66,23 @@ const Versions: React.FC = ({ collection, global }) => { docURL = `${serverURL}${api}/globals/${slug}` entityLabel = getTranslation(global.label, i18n) editURL = `${admin}/globals/${global.slug}` + + // See note above about cascading component definitions + const Edit = global?.admin?.components?.views?.Edit + CustomVersionsView = + typeof Edit === 'function' + ? Edit + : typeof Edit === 'object' && typeof Edit.Versions === 'function' + ? Edit.Versions + : typeof Edit?.Versions === 'object' && typeof Edit.Versions.Component === 'function' + ? Edit.Versions.Component + : undefined } - const useAsTitle = collection?.admin?.useAsTitle || 'id' - const [{ data: doc }] = usePayloadAPI(docURL, { initialParams: { draft: 'true' } }) + const [{ data, isLoading }] = usePayloadAPI(docURL, { initialParams: { draft: 'true' } }) const [{ data: versionsData, isLoading: isLoadingVersions }, { setParams }] = usePayloadAPI(fetchURL) - useEffect(() => { - let nav: StepNavItem[] = [] - - if (collection) { - let docLabel = '' - - if (doc) { - if (useAsTitle) { - if (doc[useAsTitle]) { - docLabel = doc[useAsTitle] - } else { - docLabel = `[${t('general:untitled')}]` - } - } else { - docLabel = doc.id - } - } - - nav = [ - { - label: getTranslation(collection.labels.plural, i18n), - url: `${admin}/collections/${collection.slug}`, - }, - { - label: docLabel, - url: editURL, - }, - { - label: t('versions'), - }, - ] - } - - if (global) { - nav = [ - { - label: getTranslation(global.label, i18n), - url: editURL, - }, - { - label: t('versions'), - }, - ] - } - - setStepNav(nav) - }, [setStepNav, collection, global, useAsTitle, doc, admin, id, editURL, t, i18n]) - useEffect(() => { const params = { depth: 1, @@ -144,79 +119,27 @@ const Versions: React.FC = ({ collection, global }) => { setParams(params) }, [setParams, page, sort, limit, serverURL, api, id, global, collection]) - let useIDLabel = doc[useAsTitle] === doc?.id - let heading: string - let metaDesc: string - let metaTitle: string - - if (collection) { - metaTitle = `${t('versions')} - ${doc[useAsTitle]} - ${entityLabel}` - metaDesc = t('viewingVersions', { documentTitle: doc[useAsTitle], entityLabel }) - heading = doc?.[useAsTitle] || `[${t('general:untitled')}]` - } - - if (global) { - metaTitle = `${t('versions')} - ${entityLabel}` - metaDesc = t('viewingVersionsGlobal', { entityLabel }) - heading = entityLabel - useIDLabel = false - } - return ( - - -
- - - -
-
{t('showingVersionsFor')}
- {useIDLabel && } - {!useIDLabel &&

{heading}

} -
- - {versionsData?.totalDocs > 0 && ( - -
-
- - {versionsData?.totalDocs > 0 && ( - -
- {versionsData.page * versionsData.limit - (versionsData.limit - 1)}- - {versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page - ? versionsData.limit * versionsData.page - : versionsData.totalDocs}{' '} - {t('of')} {versionsData.totalDocs} -
- -
- )} -
- - )} - {versionsData?.totalDocs === 0 && ( -
{t('noFurtherVersionsFound')}
- )} - - - + + + ) } - -export default Versions +export default VersionsView diff --git a/packages/payload/src/admin/components/views/Versions/types.ts b/packages/payload/src/admin/components/views/Versions/types.ts index 8e88b33ef3..5dd008ed87 100644 --- a/packages/payload/src/admin/components/views/Versions/types.ts +++ b/packages/payload/src/admin/components/views/Versions/types.ts @@ -1,7 +1,20 @@ import type { SanitizedCollectionConfig } from '../../../../collections/config/types' +import type { PaginatedDocs } from '../../../../database/types' import type { SanitizedGlobalConfig } from '../../../../globals/config/types' +import type { Version } from '../../utilities/DocumentInfo/types' -export type Props = { +export type IndexProps = { collection?: SanitizedCollectionConfig global?: SanitizedGlobalConfig } + +export type Props = IndexProps & { + data: Version + editURL: string + entityLabel: string + fetchURL: string + id: string + isLoading: boolean + isLoadingVersions: boolean + versionsData: PaginatedDocs +} diff --git a/packages/payload/src/admin/components/views/collections/Edit/index.tsx b/packages/payload/src/admin/components/views/collections/Edit/index.tsx index 4ed1cabfa8..45d9378760 100644 --- a/packages/payload/src/admin/components/views/collections/Edit/index.tsx +++ b/packages/payload/src/admin/components/views/collections/Edit/index.tsx @@ -20,29 +20,45 @@ import formatFields from './formatFields' const EditView: React.FC = (props) => { const { collection: incomingCollection, isEditing } = props - const { admin: { components: { views: { Edit: CustomEdit } = {} } = {} } = {}, slug } = + const { admin: { components: { views: { Edit } = {} } = {} } = {}, slug: collectionSlug } = incomingCollection + // The component definition could come from multiple places in the config + // we need to cascade into the proper component from the top-down + // 1. "components.Edit" + // 2. "components.Edit.Default" + // 3. "components.Edit.Default.Component" + const CustomEditView = + typeof Edit === 'function' + ? Edit + : typeof Edit === 'object' && typeof Edit.Default === 'function' + ? Edit.Default + : typeof Edit?.Default === 'object' && typeof Edit.Default.Component === 'function' + ? Edit.Default.Component + : undefined + const [fields] = useState(() => formatFields(incomingCollection, isEditing)) const [collection] = useState(() => ({ ...incomingCollection, fields })) const [redirect, setRedirect] = useState() const { code: locale } = useLocale() + const { routes: { admin, api }, serverURL, } = useConfig() + const { params: { id } = {} } = useRouteMatch>() const history = useHistory() const [internalState, setInternalState] = useState() const [updatedAt, setUpdatedAt] = useState() - const { user } = useAuth() + const { permissions, user } = useAuth() const userRef = useRef(user) const { docPermissions, getDocPermissions, getDocPreferences, getVersions } = useDocumentInfo() const { t } = useTranslation('general') const [{ data, isError, isLoading: isLoadingData }] = usePayloadAPI( - isEditing ? `${serverURL}${api}/${slug}/${id}` : null, + isEditing ? `${serverURL}${api}/${collectionSlug}/${id}` : null, { initialData: null, initialParams: { depth: 0, draft: 'true', 'fallback-locale': 'null' } }, ) @@ -107,25 +123,29 @@ const EditView: React.FC = (props) => { return } - const apiURL = `${serverURL}${api}/${slug}/${id}?locale=${locale}${ + const apiURL = `${serverURL}${api}/${collectionSlug}/${id}?locale=${locale}${ collection.versions.drafts ? '&draft=true' : '' }` - const action = `${serverURL}${api}/${slug}${ + + const action = `${serverURL}${api}/${collectionSlug}${ isEditing ? `/${id}` : '' }?locale=${locale}&fallback-locale=null` + const hasSavePermission = (isEditing && docPermissions?.update?.permission) || (!isEditing && (docPermissions as CollectionPermission)?.create?.permission) + const isLoading = !internalState || !docPermissions || isLoadingData return ( = (props) => { onSave, permissions: docPermissions, updatedAt: updatedAt || data?.updatedAt, + user, }} /> diff --git a/packages/payload/src/collections/config/schema.ts b/packages/payload/src/collections/config/schema.ts index baa4a00681..ddf06a822f 100644 --- a/packages/payload/src/collections/config/schema.ts +++ b/packages/payload/src/collections/config/schema.ts @@ -1,7 +1,7 @@ import joi from 'joi' import { endpointsSchema } from '../../config/schema' -import { componentSchema } from '../../utilities/componentSchema' +import { componentSchema, customViewSchema } from '../../config/shared/componentSchema' const strategyBaseSchema = joi.object().keys({ logout: joi.boolean(), @@ -31,7 +31,19 @@ const collectionSchema = joi.object().keys({ SaveDraftButton: componentSchema, }), views: joi.object({ - Edit: componentSchema, + Edit: joi.alternatives().try( + componentSchema, + joi.object({ + Default: joi.alternatives().try(componentSchema, customViewSchema), + Versions: joi.alternatives().try(componentSchema, customViewSchema), + // Version + // Preview + // Relationships + // References + // API + // :path + }), + ), List: componentSchema, }), }), diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index db97bcdeb4..1ef092434e 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -164,6 +164,28 @@ type BeforeDuplicateArgs = { export type BeforeDuplicate = (args: BeforeDuplicateArgs) => Promise | T +export type CollectionEditView = + | { + /** + * The component to render for this view + * + Replaces the default component + */ + Component: React.ComponentType + /** + * The label rendered in the admin UI for this view + * + Example: `default` is `Edit` + */ + label: string + /** + * The URL path to the nested collection edit views + * + Example: `/admin/collections/:collection/:id/:path` + * + The `:path` is the value of this property + * + Note: the default collection view uses no path + */ + path?: string + } + | React.ComponentType + export type CollectionAdminOptions = { /** * Custom admin components @@ -199,7 +221,33 @@ export type CollectionAdminOptions = { SaveDraftButton?: CustomSaveDraftButtonProps } views?: { - Edit?: React.ComponentType + /** + * Replaces the "Edit" view entirely + */ + Edit?: + | { + /** + * Replaces or adds nested views within the "Edit" view + * + `Default` - `/admin/collections/:collection/:id` + * + `API` - `/admin/collections/:collection/:id/api` + * + `Preview` - `/admin/collections/:collection/:id/preview` + * + `References` - `/admin/collections/:collection/:id/references` + * + `Relationships` - `/admin/collections/:collection/:id/relationships` + * + `Versions` - `/admin/collections/:collection/:id/versions` + * + `Version` - `/admin/collections/:collection/:id/versions/:version` + * + `:path` - `/admin/collections/:collection/:id/:path` + */ + Default: CollectionEditView + Versions?: CollectionEditView + // TODO: uncomment these as they are built + // [key: string]: CollectionEditView + // API?: CollectionEditView + // Preview?: CollectionEditView + // References?: CollectionEditView + // Relationships?: CollectionEditView + // Version: CollectionEditView + } + | React.ComponentType List?: React.ComponentType } } diff --git a/packages/payload/src/config/schema.ts b/packages/payload/src/config/schema.ts index 284b689733..6db44c010b 100644 --- a/packages/payload/src/config/schema.ts +++ b/packages/payload/src/config/schema.ts @@ -1,5 +1,7 @@ import joi from 'joi' +import { routeSchema } from './shared/routeSchema' + const component = joi.alternatives().try(joi.object().unknown(), joi.func()) export const endpointsSchema = joi.alternatives().try( @@ -50,15 +52,7 @@ export default joi.object({ Button: component, }), providers: joi.array().items(component), - routes: joi.array().items( - joi.object().keys({ - Component: component.required(), - exact: joi.bool(), - path: joi.string().required(), - sensitive: joi.bool(), - strict: joi.bool(), - }), - ), + routes: routeSchema, views: joi.object({ Account: component, Dashboard: component, diff --git a/packages/payload/src/utilities/componentSchema.ts b/packages/payload/src/config/shared/componentSchema.ts similarity index 50% rename from packages/payload/src/utilities/componentSchema.ts rename to packages/payload/src/config/shared/componentSchema.ts index 53c5a0cbee..b48fb5e249 100644 --- a/packages/payload/src/utilities/componentSchema.ts +++ b/packages/payload/src/config/shared/componentSchema.ts @@ -1,3 +1,9 @@ import joi from 'joi' export const componentSchema = joi.alternatives().try(joi.object().unknown(), joi.func()) + +export const customViewSchema = { + Component: componentSchema, + label: joi.string(), + path: joi.string(), +} diff --git a/packages/payload/src/config/shared/routeSchema.ts b/packages/payload/src/config/shared/routeSchema.ts new file mode 100644 index 0000000000..6f6621bb29 --- /dev/null +++ b/packages/payload/src/config/shared/routeSchema.ts @@ -0,0 +1,13 @@ +import joi from 'joi' + +import { componentSchema } from './componentSchema' + +export const routeSchema = joi.array().items( + joi.object().keys({ + Component: componentSchema, + exact: joi.bool(), + path: joi.string().required(), + sensitive: joi.bool(), + strict: joi.bool(), + }), +) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index ec329c73b9..73cee20387 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -22,8 +22,6 @@ import type { GlobalConfig, SanitizedGlobalConfig } from '../globals/config/type import type { Payload } from '../payload' import type { Where } from '../types' -import { Validate } from '../fields/config/types' - type Prettify = { [K in keyof T]: T[K] } & NonNullable @@ -195,13 +193,15 @@ export type Endpoint = { root?: boolean } -export type AdminView = React.ComponentType<{ +export type CustomAdminView = React.ComponentType<{ canAccessAdmin: boolean + collection?: SanitizedCollectionConfig + global?: SanitizedGlobalConfig user: User }> export type AdminRoute = { - Component: AdminView + Component: CustomAdminView /** Whether the path should be matched exactly or as a prefix */ exact?: boolean path: string diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index 255e610a9d..fa48480365 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -1,6 +1,6 @@ import joi from 'joi' -import { componentSchema } from '../../utilities/componentSchema' +import { componentSchema } from '../../config/shared/componentSchema' export const baseAdminComponentFields = joi .object() diff --git a/packages/payload/src/globals/config/schema.ts b/packages/payload/src/globals/config/schema.ts index 13ae1d2f24..1f3caf4581 100644 --- a/packages/payload/src/globals/config/schema.ts +++ b/packages/payload/src/globals/config/schema.ts @@ -1,7 +1,7 @@ import joi from 'joi' import { endpointsSchema } from '../../config/schema' -import { componentSchema } from '../../utilities/componentSchema' +import { componentSchema, customViewSchema } from '../../config/shared/componentSchema' const globalSchema = joi .object() @@ -20,7 +20,19 @@ const globalSchema = joi SaveDraftButton: componentSchema, }), views: joi.object({ - Edit: componentSchema, + Edit: joi.alternatives().try( + componentSchema, + joi.object({ + Default: joi.alternatives().try(componentSchema, customViewSchema), + Versions: joi.alternatives().try(componentSchema, customViewSchema), + // Version + // Preview + // Relationships + // References + // API + // :path + }), + ), }), }), description: joi.alternatives().try(joi.string(), componentSchema), diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 98e7b196af..e1e435a177 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -38,6 +38,28 @@ export type AfterReadHook = (args: { req: PayloadRequest }) => any +export type GlobalEditView = + | { + /** + * The component to render for this view + * + Replaces the default component + */ + Component: React.ComponentType + /** + * The label rendered in the admin UI for this view + * + Example: `default` is `Edit` + */ + label: string + /** + * The URL path to the nested global edit views + * + Example: `/admin/globals/:slug/:path` + * + The `:path` is the value of this property + * + Note: the default global view uses no path + */ + path?: string + } + | React.ComponentType + export type GlobalAdminOptions = { /** * Custom admin components @@ -66,7 +88,33 @@ export type GlobalAdminOptions = { SaveDraftButton?: CustomSaveDraftButtonProps } views?: { - Edit?: React.ComponentType + /** + * Replaces the "Edit" view + */ + Edit?: + | { + /** + * Replaces or adds nested routes within the "Edit" view + * + `Default` - `/admin/globals/:slug` + * + `API` - `/admin/globals/:id/api` + * + `Preview` - `/admin/globals/:id/preview` + * + `References` - `/admin/globals/:id/references` + * + `Relationships` - `/admin/globals/:id/relationships` + * + `Versions` - `/admin/globals/:id/versions` + * + `Version` - `/admin/globals/:id/versions/:version` + * + `:path` - `/admin/globals/:id/:path` + */ + Default: GlobalEditView + Versions?: GlobalEditView + // TODO: uncomment these as they are built + // [name: string]: GlobalEditView + // API?: GlobalEditView + // Preview?: GlobalEditView + // References?: GlobalEditView + // Relationships?: GlobalEditView + // Version?: GlobalEditView + } + | React.ComponentType } } /** diff --git a/test/admin/components/views/CustomDefault/index.tsx b/test/admin/components/routes/CustomDefault/index.tsx similarity index 82% rename from test/admin/components/views/CustomDefault/index.tsx rename to test/admin/components/routes/CustomDefault/index.tsx index 8dc94df5aa..e3015f786b 100644 --- a/test/admin/components/views/CustomDefault/index.tsx +++ b/test/admin/components/routes/CustomDefault/index.tsx @@ -49,14 +49,21 @@ const CustomDefaultRoute: AdminView = ({ canAccessAdmin, user }) => { title="Custom Route with Default Template" /> -

Custom Route

-

- Here is a custom route that was added in the Payload config. It uses the Default Template, - so the sidebar is rendered. -

- +
+

Custom Route

+

+ Here is a custom route that was added in the Payload config. It uses the Default Template, + so the sidebar is rendered. +

+ +
) } diff --git a/test/admin/components/views/CustomMinimal/index.scss b/test/admin/components/routes/CustomMinimal/index.scss similarity index 100% rename from test/admin/components/views/CustomMinimal/index.scss rename to test/admin/components/routes/CustomMinimal/index.scss diff --git a/test/admin/components/views/CustomMinimal/index.tsx b/test/admin/components/routes/CustomMinimal/index.tsx similarity index 100% rename from test/admin/components/views/CustomMinimal/index.tsx rename to test/admin/components/routes/CustomMinimal/index.tsx diff --git a/test/admin/components/views/CustomEdit/index.tsx b/test/admin/components/views/CustomEdit/index.tsx new file mode 100644 index 0000000000..3c63db7602 --- /dev/null +++ b/test/admin/components/views/CustomEdit/index.tsx @@ -0,0 +1,98 @@ +import React, { Fragment, useEffect } from 'react' +import { Redirect, useParams } from 'react-router-dom' + +import Button from '../../../../../packages/payload/src/admin/components/elements/Button' +import Eyebrow from '../../../../../packages/payload/src/admin/components/elements/Eyebrow' +import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav' +import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config' +import { type CustomAdminView } from '../../../../../packages/payload/src/config/types' + +const CustomEditView: CustomAdminView = ({ canAccessAdmin, collection, global, user }) => { + const { + routes: { admin: adminRoute }, + } = useConfig() + + const params = useParams() + + const { setStepNav } = useStepNav() + + // This effect will only run one time and will allow us + // to set the step nav to display our custom route name + + useEffect(() => { + setStepNav([ + { + label: 'Custom Edit View', + }, + ]) + }, [setStepNav]) + + // If an unauthorized user tries to navigate straight to this page, + // Boot 'em out + if (!user || (user && !canAccessAdmin)) { + return + } + + let versionsRoute = '' + let customRoute = '' + + if (collection) { + versionsRoute = `${adminRoute}/collections/${collection?.slug}/${params.id}/versions` + customRoute = `${adminRoute}/collections/${collection?.slug}/${params.id}/custom` + } + + if (global) { + versionsRoute = `${adminRoute}/globals/${global?.slug}/versions` + customRoute = `${adminRoute}/globals/${global?.slug}/custom` + } + + return ( + + +
+

Custom Edit View

+

This custom edit view was added through one of the following Payload configs:

+
    +
  • + components.views.Edit +

    + {'This takes precedence over the default edit view, '} + as well as all nested views like versions. +

    +
  • +
  • + components.views.Edit.Default +

    + {'This allows you to override only the default edit view, but '} + + not + + {' any nested views like versions, etc.'} +

    +
  • +
  • + components.views.Edit.Default.Component +

    + This is the most granular override, allowing you to override only the default edit + view's Component, and its other properties like path and label. +

    +
  • +
+ +       + +
+
+ ) +} + +export default CustomEditView diff --git a/test/admin/components/views/CustomVersions/index.tsx b/test/admin/components/views/CustomVersions/index.tsx new file mode 100644 index 0000000000..875abb671f --- /dev/null +++ b/test/admin/components/views/CustomVersions/index.tsx @@ -0,0 +1,85 @@ +import React, { Fragment, useEffect } from 'react' +import { Redirect, useParams } from 'react-router-dom' + +import Button from '../../../../../packages/payload/src/admin/components/elements/Button' +import Eyebrow from '../../../../../packages/payload/src/admin/components/elements/Eyebrow' +import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav' +import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config' +import { type CustomAdminView } from '../../../../../packages/payload/src/config/types' + +const CustomVersionsView: CustomAdminView = ({ canAccessAdmin, collection, global, user }) => { + const { + routes: { admin: adminRoute }, + } = useConfig() + + const params = useParams() + + const { setStepNav } = useStepNav() + + // This effect will only run one time and will allow us + // to set the step nav to display our custom route name + + useEffect(() => { + setStepNav([ + { + label: 'Custom Versions View', + }, + ]) + }, [setStepNav]) + + // If an unauthorized user tries to navigate straight to this page, + // Boot 'em out + if (!user || (user && !canAccessAdmin)) { + return + } + + let backURL = adminRoute + + if (collection) { + backURL = `${adminRoute}/collections/${collection?.slug}/${params.id}` + } + + if (global) { + backURL = `${adminRoute}/globals/${global?.slug}` + } + + return ( + + +
+

Custom Versions View

+

This custom versions view was added through one of the following Payload configs:

+
    +
  • + components.views.Versions +

    + {'This takes precedence over the default versions view, '} + as well as all nested views like /versions/:id. +

    +
  • +
  • + components.views.Edit.versions +

    Same as above.

    +
  • +
  • + components.views.Edit.versions.Component +
  • +

    + This is the most granular override, allowing you to override only the default versions + view's Component, and its other properties like path and label. +

    +
+ +
+
+ ) +} + +export default CustomVersionsView diff --git a/test/admin/components/views/CustomView/index.tsx b/test/admin/components/views/CustomView/index.tsx new file mode 100644 index 0000000000..8901973b63 --- /dev/null +++ b/test/admin/components/views/CustomView/index.tsx @@ -0,0 +1,67 @@ +import React, { Fragment, useEffect } from 'react' +import { useParams } from 'react-router-dom' + +import Button from '../../../../../packages/payload/src/admin/components/elements/Button' +import Eyebrow from '../../../../../packages/payload/src/admin/components/elements/Eyebrow' +import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav' +import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config' +import { type CustomAdminView } from '../../../../../packages/payload/src/config/types' + +const CustomView: CustomAdminView = ({ collection, global }) => { + const { + routes: { admin: adminRoute }, + } = useConfig() + + const params = useParams() + + const { setStepNav } = useStepNav() + + // This effect will only run one time and will allow us + // to set the step nav to display our custom route name + + useEffect(() => { + setStepNav([ + { + label: 'Custom View', + }, + ]) + }, [setStepNav]) + + let backURL = '' + let versionsRoute = '' + + if (collection) { + backURL = `${adminRoute}/collections/${collection?.slug}/${params.id}` + versionsRoute = `${adminRoute}/collections/${collection?.slug}/${params.id}/versions` + } + + if (global) { + backURL = `${adminRoute}/globals/${global?.slug}` + versionsRoute = `${adminRoute}/globals/${global?.slug}/versions` + } + + return ( + + +
+

Custom View

+

This custom view was added through the Payload config:

+
    +
  • + components.views[key].Component +
  • +
+ +
+
+ ) +} + +export default CustomView diff --git a/test/admin/config.ts b/test/admin/config.ts index 29447d2318..d8c308bf8a 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -9,8 +9,11 @@ import BeforeLogin from './components/BeforeLogin' import DemoUIFieldCell from './components/DemoUIField/Cell' import DemoUIFieldField from './components/DemoUIField/Field' import Logout from './components/Logout' -import CustomDefaultRoute from './components/views/CustomDefault' -import CustomMinimalRoute from './components/views/CustomMinimal' +import CustomDefaultRoute from './components/routes/CustomDefault' +import CustomMinimalRoute from './components/routes/CustomMinimal' +import CustomEditView from './components/views/CustomEdit' +import CustomVersionsView from './components/views/CustomVersions' +import CustomView from './components/views/CustomView' import { globalSlug, slug } from './shared' export interface Post { @@ -131,6 +134,48 @@ export default buildConfigWithDefaults({ }, ], }, + { + slug: 'custom-views-one', + versions: true, + admin: { + components: { + views: { + Edit: CustomEditView, + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + }, + { + slug: 'custom-views-two', + versions: true, + admin: { + components: { + views: { + Edit: { + Default: CustomEditView, + Versions: CustomVersionsView, + MyCustomView: { + path: '/custom', + Component: CustomView, + label: 'Custom', + }, + }, + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + }, { slug: 'group-one-collection-ones', admin: { @@ -218,6 +263,49 @@ export default buildConfigWithDefaults({ }, ], }, + + { + slug: 'custom-global-views-one', + versions: true, + admin: { + components: { + views: { + Edit: CustomEditView, + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + }, + { + slug: 'custom-global-views-two', + versions: true, + admin: { + components: { + views: { + Edit: { + Default: CustomEditView, + Versions: CustomVersionsView, + MyCustomView: { + path: '/custom', + Component: CustomView, + label: 'Custom', + }, + }, + }, + }, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + }, { slug: 'group-globals-one', admin: { @@ -262,6 +350,20 @@ export default buildConfigWithDefaults({ }) }) + await payload.create({ + collection: 'custom-views-one', + data: { + title: 'title', + }, + }) + + await payload.create({ + collection: 'custom-views-two', + data: { + title: 'title', + }, + }) + await payload.create({ collection: 'geo', data: { From 81010311f9505feadd8e87bd9a0dc4a92549e413 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Fri, 15 Sep 2023 16:40:08 -0400 Subject: [PATCH 3/4] chore: builds main menu modal (#3313) --- package.json | 2 + .../components/elements/Drawer/index.tsx | 4 +- .../components/elements/Eyebrow/index.tsx | 3 +- .../components/elements/Hamburger/index.scss | 67 +++++ .../components/elements/Hamburger/index.tsx | 23 ++ .../components/elements/Loading/index.scss | 5 - .../components/elements/Loading/index.tsx | 1 + .../{ => MainMenu}/NavGroup/index.scss | 26 +- .../{ => MainMenu}/NavGroup/index.tsx | 8 +- .../components/elements/MainMenu/index.scss | 126 +++++++++ .../components/elements/MainMenu/index.tsx | 153 +++++++++++ .../admin/components/elements/Nav/index.scss | 247 +++++------------- .../admin/components/elements/Nav/index.tsx | 167 ++---------- .../components/templates/Default/index.scss | 5 +- packages/payload/src/admin/scss/app.scss | 9 +- pnpm-lock.yaml | 25 +- test/access-control/e2e.spec.ts | 6 +- test/admin/components/AfterNavLinks/index.tsx | 27 +- test/admin/e2e.spec.ts | 30 +-- test/admin/styles.scss | 5 +- test/helpers.ts | 19 ++ test/localization/e2e.spec.ts | 36 +-- test/refresh-permissions/e2e.spec.ts | 7 + test/versions/e2e.spec.ts | 13 +- 24 files changed, 578 insertions(+), 436 deletions(-) create mode 100644 packages/payload/src/admin/components/elements/Hamburger/index.scss create mode 100644 packages/payload/src/admin/components/elements/Hamburger/index.tsx rename packages/payload/src/admin/components/elements/{ => MainMenu}/NavGroup/index.scss (76%) rename packages/payload/src/admin/components/elements/{ => MainMenu}/NavGroup/index.tsx (88%) create mode 100644 packages/payload/src/admin/components/elements/MainMenu/index.scss create mode 100644 packages/payload/src/admin/components/elements/MainMenu/index.tsx diff --git a/package.json b/package.json index bbd4982993..4f0adb07ba 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "prettier": "^3.0.3", "qs": "6.11.2", "react": "18.2.0", + "react-i18next": "11.18.6", + "react-router-dom": "5.3.4", "rimraf": "3.0.2", "shelljs": "0.8.5", "ts-node": "10.9.1", diff --git a/packages/payload/src/admin/components/elements/Drawer/index.tsx b/packages/payload/src/admin/components/elements/Drawer/index.tsx index ff146ed988..aca3151582 100644 --- a/packages/payload/src/admin/components/elements/Drawer/index.tsx +++ b/packages/payload/src/admin/components/elements/Drawer/index.tsx @@ -86,9 +86,7 @@ export const Drawer: React.FC = ({ id={`close-drawer__${slug}`} onClick={() => closeModal(slug)} style={{ - width: `calc(${midBreak ? 'var(--gutter-h)' : 'var(--nav-width)'} + ${ - drawerDepth - 1 - } * 25px)`, + width: 'var(--gutter-h)', }} type="button" /> diff --git a/packages/payload/src/admin/components/elements/Eyebrow/index.tsx b/packages/payload/src/admin/components/elements/Eyebrow/index.tsx index b3d9efefd7..4de44dfafa 100644 --- a/packages/payload/src/admin/components/elements/Eyebrow/index.tsx +++ b/packages/payload/src/admin/components/elements/Eyebrow/index.tsx @@ -8,11 +8,10 @@ import './index.scss' const baseClass = 'eyebrow' -const Eyebrow: React.FC = ({ actions }) => ( +const Eyebrow: React.FC = () => (
- {actions}
) diff --git a/packages/payload/src/admin/components/elements/Hamburger/index.scss b/packages/payload/src/admin/components/elements/Hamburger/index.scss new file mode 100644 index 0000000000..ad6901d4bd --- /dev/null +++ b/packages/payload/src/admin/components/elements/Hamburger/index.scss @@ -0,0 +1,67 @@ +@import '../../../scss/styles'; + +.hamburger { + position: relative; + padding: 0; + border: 0; + cursor: pointer; + width: calc(var(--base) / 1.5); + height: calc(var(--base) / 1.5); + background-color: transparent; + outline: none; + + &:focus { + outline: none; + } + + &__line { + background-color: var(--theme-text); + width: 100%; + height: 1px; + position: absolute; + } + + &__top { + top: 2px; + transform: translate3d(0, 0, 0) rotate(0); + } + + &__middle { + top: 50%; + transform: translate3d(0, -50%, 0) rotate(0); + } + + &__bottom { + bottom: 2px; + transform: translate3d(0, 0, 0) rotate(0); + } + + &__x-left { + opacity: 0; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0) rotate(45deg); + } + + &__x-right { + opacity: 0; + top: 50%; + left: 50%; + transform: translate3d(-50%, -50%, 0) rotate(-45deg); + } + + &--active { + .hamburger { + &__x-left, + &__x-right { + opacity: 1; + } + + &__top, + &__middle, + &__bottom { + opacity: 0; + } + } + } +} diff --git a/packages/payload/src/admin/components/elements/Hamburger/index.tsx b/packages/payload/src/admin/components/elements/Hamburger/index.tsx new file mode 100644 index 0000000000..78df02f42f --- /dev/null +++ b/packages/payload/src/admin/components/elements/Hamburger/index.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +import './index.scss' + +const baseClass = 'hamburger' + +const Hamburger: React.FC<{ + isActive?: boolean +}> = (props) => { + const { isActive = false } = props + + return ( +
+
+
+
+
+
+
+ ) +} + +export default Hamburger diff --git a/packages/payload/src/admin/components/elements/Loading/index.scss b/packages/payload/src/admin/components/elements/Loading/index.scss index e9ac6678f8..1720041a83 100644 --- a/packages/payload/src/admin/components/elements/Loading/index.scss +++ b/packages/payload/src/admin/components/elements/Loading/index.scss @@ -28,11 +28,6 @@ animation: fade-out ease; } - &.loading-overlay--withoutNav { - left: var(--nav-width); - width: calc(100% - var(--nav-width)); - } - &:after { content: ''; position: absolute; diff --git a/packages/payload/src/admin/components/elements/Loading/index.tsx b/packages/payload/src/admin/components/elements/Loading/index.tsx index 360461ed12..bc8debc490 100644 --- a/packages/payload/src/admin/components/elements/Loading/index.tsx +++ b/packages/payload/src/admin/components/elements/Loading/index.tsx @@ -16,6 +16,7 @@ type Props = { overlayType?: string show?: boolean } + export const LoadingOverlay: React.FC = ({ animationDuration, loadingText, diff --git a/packages/payload/src/admin/components/elements/NavGroup/index.scss b/packages/payload/src/admin/components/elements/MainMenu/NavGroup/index.scss similarity index 76% rename from packages/payload/src/admin/components/elements/NavGroup/index.scss rename to packages/payload/src/admin/components/elements/MainMenu/NavGroup/index.scss index 4a28399116..b64e242d0e 100644 --- a/packages/payload/src/admin/components/elements/NavGroup/index.scss +++ b/packages/payload/src/admin/components/elements/MainMenu/NavGroup/index.scss @@ -1,25 +1,28 @@ -@import '../../../scss/styles.scss'; +@import '../../../../scss/styles.scss'; .nav-group { width: 100%; - margin-bottom: base(0.5); + display: flex; + align-items: flex-start; + flex-direction: column; + gap: calc(var(--base) * 0.25); &__toggle { cursor: pointer; color: var(--theme-elevation-400); background: transparent; border: 0; - margin-top: base(0.25); - width: 100%; display: flex; + align-items: center; + padding: 0; + [dir='ltr'] & { - padding-right: base(0.5); padding-left: 0; align-items: flex-start; text-align: left; } + [dir='rtl'] & { - padding-left: base(0.5); padding-right: 0; align-items: flex-start; text-align: right; @@ -27,7 +30,6 @@ svg { flex-shrink: 0; - margin-top: base(-0.2); } &:hover, @@ -44,6 +46,16 @@ } } + &__label { + margin: 0; + } + + &__content { + display: flex; + flex-direction: column; + gap: calc(var(--base) * 0.25); + } + &__indicator { [dir='ltr'] & { margin-left: auto; diff --git a/packages/payload/src/admin/components/elements/NavGroup/index.tsx b/packages/payload/src/admin/components/elements/MainMenu/NavGroup/index.tsx similarity index 88% rename from packages/payload/src/admin/components/elements/NavGroup/index.tsx rename to packages/payload/src/admin/components/elements/MainMenu/NavGroup/index.tsx index 5ba3198ee6..561000dd17 100644 --- a/packages/payload/src/admin/components/elements/NavGroup/index.tsx +++ b/packages/payload/src/admin/components/elements/MainMenu/NavGroup/index.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react' import AnimateHeight from 'react-animate-height' -import Chevron from '../../icons/Chevron' -import { usePreferences } from '../../utilities/Preferences' +import Chevron from '../../../icons/Chevron' +import { usePreferences } from '../../../utilities/Preferences' import './index.scss' const baseClass = 'nav-group' @@ -44,7 +44,7 @@ const NavGroup: React.FC = ({ children, label }) => { return (
= ({ children, label }) => { onClick={toggleCollapsed} type="button" > -
{label}
+

{label}

diff --git a/packages/payload/src/admin/components/elements/MainMenu/index.scss b/packages/payload/src/admin/components/elements/MainMenu/index.scss new file mode 100644 index 0000000000..927354396c --- /dev/null +++ b/packages/payload/src/admin/components/elements/MainMenu/index.scss @@ -0,0 +1,126 @@ +@import '../../../scss/styles.scss'; + +$transTime: 200ms; + +.main-menu { + display: flex; + position: fixed; + height: 100vh; + + &__blur-bg { + @include blur-bg(); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0; + transition: all $transTime linear; + } + + &__content { + padding: base(1) 0 base(2); + position: relative; + opacity: 0; + transform: translateX(calc(var(--base) * -1)); + transition: transform $transTime linear; + overflow: auto; + width: 50%; + } + + &__content-children { + position: relative; + display: flex; + flex-direction: column; + gap: var(--base); + } + + &__close { + @extend %btn-reset; + position: relative; + z-index: 2; + flex-shrink: 0; + text-indent: -9999px; + background: rgba(0, 0, 0, 0.2); + cursor: pointer; + opacity: 0; + will-change: opacity; + transition: none; + transition-delay: 0ms; + flex-grow: 1; + flex-shrink: 1; + + &:active, + &:focus { + outline: 0; + } + + &::before { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: calc(var(--base) * 4); + content: ' '; + background: linear-gradient(to right, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0) 100%); + } + } + + &--is-open { + .main-menu { + &__content, + &__blur-bg, + &__close { + opacity: 1; + } + + &__close { + transition: opacity $transTime ease-in-out; + transition-delay: $transTime; + } + + &__content { + transform: translateX(0); + } + } + } + + &__link { + margin: 0; + + a { + text-decoration: none; + } + + &:hover { + text-decoration: underline; + } + } + + &__controls { + display: flex; + flex-direction: column; + gap: calc(var(--base) / 2); + } + + &.payload__modal-item--exitActive { + transition: none; + } + + @include mid-break { + .main-menu { + &__close { + display: none; + } + + &__content { + width: 100%; + padding-top: calc(var(--base) * 2); + + &::after { + display: none; + } + } + } + } +} diff --git a/packages/payload/src/admin/components/elements/MainMenu/index.tsx b/packages/payload/src/admin/components/elements/MainMenu/index.tsx new file mode 100644 index 0000000000..1067ee2202 --- /dev/null +++ b/packages/payload/src/admin/components/elements/MainMenu/index.tsx @@ -0,0 +1,153 @@ +import { Modal, useModal } from '@faceless-ui/modal' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link, NavLink } from 'react-router-dom' + +import type { EntityToGroup, Group } from '../../../utilities/groupNavItems' + +import { getTranslation } from '../../../../utilities/getTranslation' +import { EntityType, groupNavItems } from '../../../utilities/groupNavItems' +import Account from '../../graphics/Account' +import { useAuth } from '../../utilities/Auth' +import { useConfig } from '../../utilities/Config' +import { Gutter } from '../Gutter' +import Localizer from '../Localizer' +import Logout from '../Logout' +import NavGroup from './NavGroup' +import './index.scss' + +const baseClass = 'main-menu' + +export const mainMenuSlug = 'main-menu' + +export const MainMenuDrawer: React.FC = () => { + const { permissions, user } = useAuth() + const { closeModal, modalState } = useModal() + + const { i18n, t } = useTranslation('general') + + const { + admin: { + components: { afterNavLinks, beforeNavLinks }, + }, + collections, + globals, + routes: { admin }, + } = useConfig() + + const [groups, setGroups] = useState([]) + + const [isOpen, setIsOpen] = useState(false) + + useEffect(() => { + setIsOpen(modalState[mainMenuSlug]?.isOpen) + }, [modalState]) + + useEffect(() => { + setGroups( + groupNavItems( + [ + ...collections + .filter( + ({ admin: { hidden } }) => + !(typeof hidden === 'function' ? hidden({ user }) : hidden), + ) + .map((collection) => { + const entityToGroup: EntityToGroup = { + entity: collection, + type: EntityType.collection, + } + + return entityToGroup + }), + ...globals + .filter( + ({ admin: { hidden } }) => + !(typeof hidden === 'function' ? hidden({ user }) : hidden), + ) + .map((global) => { + const entityToGroup: EntityToGroup = { + entity: global, + type: EntityType.global, + } + + return entityToGroup + }), + ], + permissions, + i18n, + ), + ) + }, [collections, globals, permissions, i18n, i18n.language, user]) + + return ( + +
+ + - - -
- + +
+
+
+ + + +
+
+ +
) } diff --git a/packages/payload/src/admin/components/templates/Default/index.scss b/packages/payload/src/admin/components/templates/Default/index.scss index 7d8629cda0..6cdb026a03 100644 --- a/packages/payload/src/admin/components/templates/Default/index.scss +++ b/packages/payload/src/admin/components/templates/Default/index.scss @@ -2,7 +2,6 @@ .template-default { min-height: 100vh; - display: flex; &__wrap { min-width: 0; @@ -11,15 +10,15 @@ } @include mid-break { - display: block; width: 100%; + [dir='ltr'] & { margin-left: 0; } + [dir='rtl'] & { margin-right: 0; } - padding-top: base(3); &__wrap { padding: 0 0 $baseline; diff --git a/packages/payload/src/admin/scss/app.scss b/packages/payload/src/admin/scss/app.scss index 5f6b490615..89f9878b3e 100644 --- a/packages/payload/src/admin/scss/app.scss +++ b/packages/payload/src/admin/scss/app.scss @@ -13,7 +13,6 @@ --breakpoint-m-width: #{$breakpoint-m-width}; --breakpoint-l-width: #{$breakpoint-l-width}; --scrollbar-width: 17px; - --nav-width: #{base(9)}; --theme-bg: var(--theme-elevation-0); --theme-input-bg: var(--theme-elevation-0); @@ -36,16 +35,10 @@ --accessibility-outline: 2px solid var(--theme-text); --accessibility-outline-offset: 2px; - --gutter-h: #{base(5)}; - - @include large-break { - --gutter-h: #{base(3)}; - --nav-width: #{base(8)}; - } + --gutter-h: #{base(3)}; @include mid-break { --gutter-h: #{base(2)}; - --nav-width: 0px; } @include small-break { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c8b7a6fb5..894c6d5808 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,18 +74,21 @@ importers: prettier: specifier: ^3.0.3 version: 3.0.3 -<<<<<<< HEAD qs: specifier: 6.11.2 version: 6.11.2 react: specifier: 18.2.0 version: 18.2.0 -======= + react-i18next: + specifier: 11.18.6 + version: 11.18.6(i18next@22.5.1)(react-dom@18.2.0)(react@18.2.0) + react-router-dom: + specifier: 5.3.4 + version: 5.3.4(react@18.2.0) rimraf: specifier: 3.0.2 version: 3.0.2 ->>>>>>> dd0514bd2cd312dbbc01ec8f018ff575bb93182f shelljs: specifier: 0.8.5 version: 0.8.5 @@ -8515,13 +8518,11 @@ packages: tiny-invariant: 1.3.1 tiny-warning: 1.0.3 value-equal: 1.0.1 - dev: false /hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} dependencies: react-is: 16.13.1 - dev: false /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -8566,7 +8567,6 @@ packages: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} dependencies: void-elements: 3.1.0 - dev: false /html-webpack-plugin@5.5.3(webpack@5.88.2): resolution: {integrity: sha512-6YrDKTuqaP/TquFH7h4srYWsZx+x6k6+FbsTm0ziCwGHDP78Unr1r9F/H4+sGmMbX08GQcJ+K64x55b+7VM/jg==} @@ -8690,7 +8690,6 @@ packages: resolution: {integrity: sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==} dependencies: '@babel/runtime': 7.22.11 - dev: false /iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} @@ -10715,7 +10714,6 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - dev: false /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} @@ -11147,7 +11145,6 @@ packages: resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==} dependencies: isarray: 0.0.1 - dev: false /path-type@3.0.0: resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} @@ -12187,7 +12184,6 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - dev: false /proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -12425,11 +12421,9 @@ packages: i18next: 22.5.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: false /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - dev: false /react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -12474,7 +12468,6 @@ packages: react-router: 5.3.4(react@18.2.0) tiny-invariant: 1.3.1 tiny-warning: 1.0.3 - dev: false /react-router-navigation-prompt@1.9.6(react-router-dom@5.3.4)(react@18.2.0): resolution: {integrity: sha512-l0sAtbroHK8i1/Eyy29XcrMpBEt0R08BaScgMUt8r5vWWbLz7G0ChOikayTCQm7QgDFsHw8gVnxDJb7TBZCAKg==} @@ -12501,7 +12494,6 @@ packages: react-is: 16.13.1 tiny-invariant: 1.3.1 tiny-warning: 1.0.3 - dev: false /react-select@5.7.4(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-NhuE56X+p9QDFh4BgeygHFIvJJszO1i1KSkg/JPcIJrbovyRtI+GuOEa4XzFCEpZRAEoEI8u/cAHK+jG/PgUzQ==} @@ -12824,7 +12816,6 @@ packages: /resolve-pathname@3.0.0: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} - dev: false /resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -13816,11 +13807,9 @@ packages: /tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} - dev: false /tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - dev: false /titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} @@ -14371,7 +14360,6 @@ packages: /value-equal@1.0.1: resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} - dev: false /vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} @@ -14380,7 +14368,6 @@ packages: /void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} - dev: false /w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index 926bf8c00c..dcbfd22803 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -6,6 +6,7 @@ import type { ReadOnlyCollection, RestrictedVersion } from './payload-types' import payload from '../../packages/payload/src' import wait from '../../packages/payload/src/utilities/wait' +import { openMainMenu } from '../helpers' import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' import { @@ -99,6 +100,7 @@ describe('access control', () => { test('should not show in nav', async () => { await page.goto(url.admin) + await openMainMenu(page) await expect(page.locator('.nav >> a:has-text("Restricteds")')).toHaveCount(0) }) @@ -137,7 +139,9 @@ describe('access control', () => { test('should show in nav', async () => { await page.goto(url.admin) - await expect(page.locator(`.nav a[href="/admin/collections/${readOnlySlug}"]`)).toHaveCount(1) + await expect( + page.locator(`.main-menu a[href="/admin/collections/${readOnlySlug}"]`), + ).toHaveCount(1) }) test('should have collection url', async () => { diff --git a/test/admin/components/AfterNavLinks/index.tsx b/test/admin/components/AfterNavLinks/index.tsx index bc4c6f145a..dfdab7f359 100644 --- a/test/admin/components/AfterNavLinks/index.tsx +++ b/test/admin/components/AfterNavLinks/index.tsx @@ -2,11 +2,9 @@ import React from 'react' import { NavLink } from 'react-router-dom' // As this is the demo project, we import our dependencies from the `src` directory. -import Chevron from '../../../../packages/payload/src/admin/components/icons/Chevron' import { useConfig } from '../../../../packages/payload/src/admin/components/utilities/Config' // In your projects, you can import as follows: -// import { Chevron } from 'payload/components'; // import { useConfig } from 'payload/components/utilities'; const baseClass = 'after-nav-links' @@ -17,26 +15,35 @@ const AfterNavLinks: React.FC = () => { } = useConfig() return ( -
- Custom Routes - +
) } diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index d7c49e1f55..0da758f929 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -8,7 +8,7 @@ import type { Post } from './config' import payload from '../../packages/payload/src' import { mapAsync } from '../../packages/payload/src/utilities/mapAsync' import wait from '../../packages/payload/src/utilities/wait' -import { saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers' +import { openMainMenu, saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers' import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' import { globalSlug, slug } from './shared' @@ -45,27 +45,29 @@ describe('admin', () => { describe('Nav', () => { test('should nav to collection - sidebar', async () => { await page.goto(url.admin) - const collectionLink = page.locator(`#nav-${slug}`) - await collectionLink.click() - + await openMainMenu(page) + await page.locator(`#nav-${slug}`).click() expect(page.url()).toContain(url.list) }) test('should nav to a global - sidebar', async () => { await page.goto(url.admin) + await openMainMenu(page) await page.locator(`#nav-global-${globalSlug}`).click() - expect(page.url()).toContain(url.global(globalSlug)) }) test('should navigate to collection - card', async () => { await page.goto(url.admin) + await wait(200) await page.locator(`#card-${slug}`).click() expect(page.url()).toContain(url.list) }) test('should collapse and expand collection groups', async () => { await page.goto(url.admin) + await openMainMenu(page) + const navGroup = page.locator('#nav-group-One .nav-group__toggle') const link = page.locator('#nav-group-one-collection-ones') @@ -81,6 +83,8 @@ describe('admin', () => { test('should collapse and expand globals groups', async () => { await page.goto(url.admin) + await openMainMenu(page) + const navGroup = page.locator('#nav-group-Group .nav-group__toggle') const link = page.locator('#nav-global-group-globals-one') @@ -96,12 +100,9 @@ describe('admin', () => { test('should save nav group collapse preferences', async () => { await page.goto(url.admin) - - const navGroup = page.locator('#nav-group-One .nav-group__toggle') - await navGroup.click() - + await openMainMenu(page) + await page.locator('#nav-group-One .nav-group__toggle').click() await page.goto(url.admin) - const link = page.locator('#nav-group-one-collection-ones') await expect(link).toBeHidden() }) @@ -189,13 +190,11 @@ describe('admin', () => { }) test('should delete existing', async () => { - const { id, ...post } = await createPost() - + const { id, title } = await createPost() await page.goto(url.edit(id)) await page.locator('#action-delete').click() await page.locator('#confirm-delete').click() - - await expect(page.locator(`text=Post en "${post.title}" successfully deleted.`)).toBeVisible() + await expect(page.locator(`text=Post en "${title}" successfully deleted.`)).toBeVisible() expect(page.url()).toContain(url.list) }) @@ -724,7 +723,8 @@ describe('admin', () => { describe('custom css', () => { test('should see custom css in admin UI', async () => { await page.goto(url.admin) - const navControls = page.locator('.nav__controls') + await openMainMenu(page) + const navControls = page.locator('.main-menu__controls') await expect(navControls).toHaveCSS('font-family', 'monospace') }) }) diff --git a/test/admin/styles.scss b/test/admin/styles.scss index 4a36b19822..755bd6b62d 100644 --- a/test/admin/styles.scss +++ b/test/admin/styles.scss @@ -1,7 +1,8 @@ -.nav__controls { +.main-menu__controls { font-family: monospace; background-image: url('/placeholder.png'); } -.nav__controls:before { + +.main-menu__controls:before { content: 'custom-css'; } diff --git a/test/helpers.ts b/test/helpers.ts index 740ae5f87c..53810ff3e1 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -55,3 +55,22 @@ export async function saveDocAndAssert(page: Page, selector = '#action-save'): P await expect(page.locator('.Toastify')).toContainText('successfully') expect(page.url()).not.toContain('create') } + +export async function openMainMenu(page: Page): Promise { + await page.locator('.payload__modal-toggler--slug-main-menu').click() + const mainMenuModal = page.locator('#main-menu') + await expect(mainMenuModal).toBeVisible() +} + +export async function closeMainMenu(page: Page): Promise { + await page.locator('.payload__modal-toggler--slug-main-menu--is-open').click() + const mainMenuModal = page.locator('#main-menu') + await expect(mainMenuModal).toBeHidden() +} + +export async function changeLocale(page: Page, newLocale: string) { + await openMainMenu(page) + await page.locator('.localizer >> button').first().click() + await page.locator(`.localizer >> a:has-text("${newLocale}")`).click() + expect(page.url()).toContain(`locale=${newLocale}`) +} diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 96b76d26b0..042943cbe5 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test' import type { LocalizedPost } from './payload-types' import payload from '../../packages/payload/src' -import { saveDocAndAssert } from '../helpers' +import { changeLocale, saveDocAndAssert } from '../helpers' import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadTest } from '../helpers/configHelpers' import { englishTitle, localizedPostsSlug, spanishLocale } from './shared' @@ -29,6 +29,7 @@ const arabicTitle = 'arabic title' const description = 'description' let page: Page + describe('Localization', () => { beforeAll(async ({ browser }) => { const { serverURL } = await initPayloadTest({ @@ -52,7 +53,7 @@ describe('Localization', () => { await saveDocAndAssert(page) // Change back to English - await changeLocale('es') + await changeLocale(page, 'es') // Localized field should not be populated await expect(page.locator('#field-title')).toBeEmpty() @@ -60,7 +61,7 @@ describe('Localization', () => { await fillValues({ title: spanishTitle, description }) await saveDocAndAssert(page) - await changeLocale(defaultLocale) + await changeLocale(page, defaultLocale) // Expect english title await expect(page.locator('#field-title')).toHaveValue(title) @@ -73,13 +74,13 @@ describe('Localization', () => { const newLocale = 'es' // Change to Spanish - await changeLocale(newLocale) + await changeLocale(page, newLocale) await fillValues({ title: spanishTitle, description }) await saveDocAndAssert(page) // Change back to English - await changeLocale(defaultLocale) + await changeLocale(page, defaultLocale) // Localized field should not be populated await expect(page.locator('#field-title')).toBeEmpty() @@ -101,13 +102,13 @@ describe('Localization', () => { const newLocale = 'ar' // Change to Arabic - await changeLocale(newLocale) + await changeLocale(page, newLocale) await fillValues({ title: arabicTitle, description }) await saveDocAndAssert(page) // Change back to English - await changeLocale(defaultLocale) + await changeLocale(page, defaultLocale) // Localized field should not be populated await expect(page.locator('#field-title')).toBeEmpty() @@ -125,16 +126,16 @@ describe('Localization', () => { }) describe('localized duplicate', () => { - let id - - beforeAll(async () => { + test('should duplicate data for all locales', async () => { const localizedPost = await payload.create({ collection: localizedPostsSlug, data: { title: englishTitle, }, }) - id = localizedPost.id + + const id = localizedPost.id.toString() + await payload.update({ collection: localizedPostsSlug, id, @@ -143,17 +144,12 @@ describe('Localization', () => { title: spanishTitle, }, }) - }) - test('should duplicate data for all locales', async () => { await page.goto(url.edit(id)) - await page.locator('.btn.duplicate').first().click() await expect(page.locator('.Toastify')).toContainText('successfully') - await expect(page.locator('#field-title')).toHaveValue(englishTitle) - - await changeLocale(spanishLocale) + await changeLocale(page, spanishLocale) await expect(page.locator('#field-title')).toHaveValue(spanishTitle) }) }) @@ -165,9 +161,3 @@ async function fillValues(data: Partial) { if (titleVal) await page.locator('#field-title').fill(titleVal) if (descVal) await page.locator('#field-description').fill(descVal) } - -async function changeLocale(newLocale: string) { - await page.locator('.localizer >> button').first().click() - await page.locator(`.localizer >> a:has-text("${newLocale}")`).click() - expect(page.url()).toContain(`locale=${newLocale}`) -} diff --git a/test/refresh-permissions/e2e.spec.ts b/test/refresh-permissions/e2e.spec.ts index 2d52660b60..7b64f1c3ba 100644 --- a/test/refresh-permissions/e2e.spec.ts +++ b/test/refresh-permissions/e2e.spec.ts @@ -2,6 +2,7 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' +import { closeMainMenu, openMainMenu } from '../helpers' import { initPayloadE2E } from '../helpers/configHelpers' const { beforeAll, describe } = test @@ -19,6 +20,8 @@ describe('refresh-permissions', () => { test('should show test global immediately after allowing access', async () => { await page.goto(`${serverURL}/admin/globals/settings`) + await openMainMenu(page) + // Ensure that we have loaded accesses by checking that settings collection // at least is visible in the menu. await expect(page.locator('#nav-global-settings')).toBeVisible() @@ -26,10 +29,14 @@ describe('refresh-permissions', () => { // Test collection should be hidden at first. await expect(page.locator('#nav-global-test')).toBeHidden() + await closeMainMenu(page) + // Allow access to test global. await page.locator('.custom-checkbox:has(#field-test) input').check() await page.locator('#action-save').click() + await openMainMenu(page) + // Now test collection should appear in the menu. await expect(page.locator('#nav-global-test')).toBeVisible() }) diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index eec0606f25..dfa03ee6c7 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -28,6 +28,7 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import wait from '../../packages/payload/src/utilities/wait' +import { changeLocale } from '../helpers' import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' import { autosaveSlug, draftSlug } from './shared' @@ -130,25 +131,19 @@ describe('versions', () => { await page.locator('#field-description').fill(description) await wait(500) - await changeLocale(spanishLocale) + await changeLocale(page, spanishLocale) await page.locator('#field-title').fill(spanishTitle) await wait(500) - await changeLocale(locale) + await changeLocale(page, locale) await page.locator('#field-description').fill(newDescription) await wait(500) - await changeLocale(spanishLocale) + await changeLocale(page, spanishLocale) await wait(500) await page.reload() await expect(page.locator('#field-title')).toHaveValue(spanishTitle) await expect(page.locator('#field-description')).toHaveValue(newDescription) }) }) - - async function changeLocale(newLocale: string) { - await page.locator('.localizer >> button').first().click() - await page.locator(`.localizer >> a:has-text("${newLocale}")`).click() - expect(page.url()).toContain(`locale=${newLocale}`) - } }) From 26955bb331e2511ae595699b0dcede16f1b47e70 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Fri, 15 Sep 2023 17:07:22 -0400 Subject: [PATCH 4/4] chore: fix up tests for globals, hooks, localization --- test/.eslintrc.cjs | 1 + test/globals/int.spec.ts | 2 +- test/hooks/int.spec.ts | 4 ++- test/localization/int.spec.ts | 57 +++++++++++++++++++---------------- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/test/.eslintrc.cjs b/test/.eslintrc.cjs index 865656a50e..cbda3d2e75 100644 --- a/test/.eslintrc.cjs +++ b/test/.eslintrc.cjs @@ -32,6 +32,7 @@ module.exports = { { files: ['**/int.spec.ts'], rules: { + '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-use-before-define': 'off', 'jest/prefer-strict-equal': 'off', diff --git a/test/globals/int.spec.ts b/test/globals/int.spec.ts index 34f27767a4..5cf171194d 100644 --- a/test/globals/int.spec.ts +++ b/test/globals/int.spec.ts @@ -70,7 +70,7 @@ describe('globals', () => { describe('local', () => { it('should save empty json objects', async () => { - const createdJSON = await payload.updateGlobal({ + const createdJSON: any = await payload.updateGlobal({ slug, data: { json: { diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index 2a7207a041..03b6c60fcc 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -1,3 +1,5 @@ +import type { NestedAfterReadHook } from './payload-types' + import payload from '../../packages/payload/src' import { AuthenticationError } from '../../packages/payload/src/errors' import { devUser, regularUser } from '../credentials' @@ -77,7 +79,7 @@ describe('Hooks', () => { }) it('should save data generated with afterRead hooks in nested field structures', async () => { - const document = await payload.create({ + const document: NestedAfterReadHook = await payload.create({ collection: nestedAfterReadHooksSlug, data: { text: 'ok', diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 29597c3a59..c685e2cf83 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -17,10 +17,10 @@ import { relationSpanishTitle, relationSpanishTitle2, relationshipLocalizedSlug, - withLocalizedRelSlug, - withRequiredLocalizedFields, spanishLocale, spanishTitle, + withLocalizedRelSlug, + withRequiredLocalizedFields, } from './shared' const collection = localizedPostsSlug @@ -36,6 +36,7 @@ describe('Localization', () => { ;({ serverURL } = await initPayloadTest({ __dirname, init: { local: false } })) config = await configPromise + // @ts-expect-error Force typing post1 = await payload.create({ collection, data: { @@ -43,6 +44,7 @@ describe('Localization', () => { }, }) + // @ts-expect-error Force typing postWithLocalizedData = await payload.create({ collection, data: { @@ -89,7 +91,7 @@ describe('Localization', () => { expect(updated.title).toEqual(spanishTitle) - const localized = await payload.findByID({ + const localized: any = await payload.findByID({ collection, id: post1.id, locale: 'all', @@ -111,7 +113,7 @@ describe('Localization', () => { expect(updated.title).toEqual(englishTitle) - const localizedFallback = await payload.findByID({ + const localizedFallback: any = await payload.findByID({ collection, id: post1.id, locale: 'all', @@ -131,6 +133,7 @@ describe('Localization', () => { }, }) + // @ts-expect-error Force typing localizedPost = await payload.update({ collection, id, @@ -171,7 +174,7 @@ describe('Localization', () => { }) it('all locales', async () => { - const localized = await payload.findByID({ + const localized: any = await payload.findByID({ collection, locale: 'all', id: localizedPost.id, @@ -191,8 +194,8 @@ describe('Localization', () => { }, }) - expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id); - }); + expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) + }) it('by localized field value - alternate locale', async () => { const result = await payload.find({ @@ -205,8 +208,8 @@ describe('Localization', () => { }, }) - expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id); - }); + expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) + }) it('by localized field value - opposite locale???', async () => { const result = await payload.find({ @@ -219,10 +222,10 @@ describe('Localization', () => { }, }) - expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id); - }); - }); - }); + expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id) + }) + }) + }) describe('Localized Relationship', () => { let localizedRelation: LocalizedPost @@ -243,6 +246,7 @@ describe('Localization', () => { }, }) + // @ts-expect-error Force typing withRelationship = await payload.create({ collection: withLocalizedRelSlug, data: { @@ -304,15 +308,16 @@ describe('Localization', () => { it('populates relationships with all locales', async () => { // the relationship fields themselves are localized on this collection - const result = await payload.find({ + const result: any = await payload.find({ collection: relationshipLocalizedSlug, locale: 'all', depth: 1, }) - expect((result.docs[0].relationship as any).en.id).toBeDefined() - expect((result.docs[0].relationshipHasMany as any).en[0].id).toBeDefined() - expect((result.docs[0].relationMultiRelationTo as any).en.value.id).toBeDefined() - expect((result.docs[0].relationMultiRelationToHasMany as any).en[0].value.id).toBeDefined() + + expect(result.docs[0].relationship.en.id).toBeDefined() + expect(result.docs[0].relationshipHasMany.en[0].id).toBeDefined() + expect(result.docs[0].relationMultiRelationTo.en.value.id).toBeDefined() + expect(result.docs[0].relationMultiRelationToHasMany.en[0].value.id).toBeDefined() expect(result.docs[0].arrayField.en[0].nestedRelation.id).toBeDefined() }) }) @@ -328,7 +333,7 @@ describe('Localization', () => { }, }) - expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id); + expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id) // Second relationship const result2 = await payload.find({ @@ -340,8 +345,8 @@ describe('Localization', () => { }, }) - expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id); - }); + expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id) + }) it('specific locale', async () => { const result = await payload.find({ @@ -395,7 +400,7 @@ describe('Localization', () => { }, }) - expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id); + expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id) // First relationship - spanish const result2 = await queryRelation({ @@ -404,7 +409,7 @@ describe('Localization', () => { }, }) - expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id); + expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id) // Second relationship - english const result3 = await queryRelation({ @@ -413,7 +418,7 @@ describe('Localization', () => { }, }) - expect(result3.docs.map(({ id }) => id)).toContain(withRelationship.id); + expect(result3.docs.map(({ id }) => id)).toContain(withRelationship.id) // Second relationship - spanish const result4 = await queryRelation({ @@ -509,7 +514,7 @@ describe('Localization', () => { describe('Localized - arrays with nested localized fields', () => { it('should allow moving rows and retain existing row locale data', async () => { - const globalArray = await payload.findGlobal({ + const globalArray: any = await payload.findGlobal({ slug: 'global-array', }) @@ -753,7 +758,7 @@ async function createLocalizedPost(data: { [spanishLocale]: string } }): Promise { - const localizedRelation = await payload.create({ + const localizedRelation: any = await payload.create({ collection, data: { title: data.title.en,