From 4e2f2561ffe16d9bff1a27a6574fcb36dde54acc Mon Sep 17 00:00:00 2001 From: Alessio Gravili <70709113+AlessioGr@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:34:11 +0200 Subject: [PATCH 01/52] chore: add section for design contributions in contributing.md --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d50a836ef2..9c9e0f61907 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,12 @@ Payload documentation can be found directly within its codebase and you can feel If you're an incredibly awesome person and want to help us make Payload even better through new features or additions, we would be thrilled to work with you. +## Design Contributions + +When it comes to design-related changes or additions, it's crucial for us to ensure a cohesive user experience and alignment with our broader design vision. Before embarking on any implementation that would affect the design or UI/UX, we ask that you **first share your design proposal** with us for review and approval. + +Our design review ensures that proposed changes fit seamlessly with other components, both existing and planned. This step is meant to prevent unintentional design inconsistencies and to save you from investing time in implementing features that might need significant design alterations later. + ### Before Starting To help us work on new features, you can create a new feature request post in [GitHub Discussion](https://github.com/payloadcms/payload/discussions) or discuss it in our [Discord](https://discord.com/invite/payload). New functionality often has large implications across the entire Payload repo, so it is best to discuss the architecture and approach before starting work on a pull request. From 33686c6db8373a16d7f6b0192e0701bf15881aa4 Mon Sep 17 00:00:00 2001 From: Stef Gootzen <37367280+stefgootzen@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:37:01 +0200 Subject: [PATCH 02/52] feat: add afterOperation hook (#2697) * feat: add afterOperation hook for Find operation * docs: change #afterOperation to #afteroperation * chore: extract afterOperation in function * chore: implement afterChange in operations * docs: use proper CollectionAfterOperationHook * chore: remove outdated info * chore: types afterOperation hook * chore: improves afterOperation tests * docs: updates description of afterOperation hook * chore: improve typings * chore: improve types * chore: rename index.tsx => index.ts --------- Co-authored-by: Jacob Fletcher Co-authored-by: Alessio Gravili --- docs/hooks/collections.mdx | 63 ++++++++++------ src/auth/operations/forgotPassword.ts | 11 +++ src/auth/operations/login.ts | 22 ++++-- src/auth/operations/refresh.ts | 22 ++++-- src/collections/config/defaults.ts | 1 + src/collections/config/schema.ts | 1 + src/collections/config/types.ts | 9 +++ src/collections/operations/create.ts | 15 +++- src/collections/operations/delete.ts | 15 +++- src/collections/operations/deleteByID.ts | 13 +++- src/collections/operations/find.ts | 11 +++ src/collections/operations/findByID.ts | 11 +++ src/collections/operations/update.ts | 18 ++++- src/collections/operations/updateByID.ts | 11 +++ src/collections/operations/utils.ts | 71 +++++++++++++++++++ .../hooks/collections/AfterOperation/index.ts | 71 +++++++++++++++++++ test/hooks/config.ts | 2 + test/hooks/int.spec.ts | 63 +++++++++++++++- test/hooks/payload-types.ts | 62 ++++++++-------- types.d.ts | 1 + 20 files changed, 426 insertions(+), 67 deletions(-) create mode 100644 src/collections/operations/utils.ts create mode 100644 test/hooks/collections/AfterOperation/index.ts diff --git a/docs/hooks/collections.mdx b/docs/hooks/collections.mdx index 8923f652771..b998dd9a0f4 100644 --- a/docs/hooks/collections.mdx +++ b/docs/hooks/collections.mdx @@ -16,6 +16,7 @@ Collections feature the ability to define the following hooks: - [afterRead](#afterread) - [beforeDelete](#beforedelete) - [afterDelete](#afterdelete) +- [afterOperation](#afteroperation) Additionally, `auth`-enabled collections feature the following hooks: @@ -31,6 +32,7 @@ Additionally, `auth`-enabled collections feature the following hooks: All collection Hook properties accept arrays of synchronous or asynchronous functions. Each Hook type receives specific arguments and has the ability to modify specific outputs. `collections/exampleHooks.js` + ```ts import { CollectionConfig } from 'payload/types'; @@ -48,6 +50,7 @@ export const ExampleHooks: CollectionConfig = { afterChange: [(args) => {...}], afterRead: [(args) => {...}], afterDelete: [(args) => {...}], + afterOperation: [(args) => {...}], // Auth-enabled hooks beforeLogin: [(args) => {...}], @@ -62,19 +65,19 @@ export const ExampleHooks: CollectionConfig = { ### beforeOperation -The `beforeOperation` Hook type can be used to modify the arguments that operations accept or execute side-effects that run before an operation begins. +The `beforeOperation` hook can be used to modify the arguments that operations accept or execute side-effects that run before an operation begins. -Available Collection operations include `create`, `read`, `update`, `delete`, `login`, `refresh` and `forgotPassword`. +Available Collection operations include `create`, `read`, `update`, `delete`, `login`, `refresh`, and `forgotPassword`. ```ts -import { CollectionBeforeOperationHook } from 'payload/types'; +import { CollectionBeforeOperationHook } from "payload/types"; const beforeOperationHook: CollectionBeforeOperationHook = async ({ - args, // Original arguments passed into the operation + args, // original arguments passed into the operation operation, // name of the operation }) => { - return args; // Return operation arguments as necessary -} + return args; // return modified operation arguments as necessary +}; ``` ### beforeValidate @@ -88,7 +91,7 @@ Please do note that this does not run before the client-side validation. If you 3. `validate` runs on the server ```ts -import { CollectionBeforeOperationHook } from 'payload/types'; +import { CollectionBeforeOperationHook } from "payload/types"; const beforeValidateHook: CollectionBeforeValidateHook = async ({ data, // incoming data to update or create with @@ -97,7 +100,7 @@ const beforeValidateHook: CollectionBeforeValidateHook = async ({ originalDoc, // original document }) => { return data; // Return data to either create or update a document with -} +}; ``` ### beforeChange @@ -105,7 +108,7 @@ const beforeValidateHook: CollectionBeforeValidateHook = async ({ Immediately following validation, `beforeChange` hooks will run within `create` and `update` operations. At this stage, you can be confident that the data that will be saved to the document is valid in accordance to your field validations. You can optionally modify the shape of data to be saved. ```ts -import { CollectionBeforeChangeHook } from 'payload/types'; +import { CollectionBeforeChangeHook } from "payload/types"; const beforeChangeHook: CollectionBeforeChangeHook = async ({ data, // incoming data to update or create with @@ -114,7 +117,7 @@ const beforeChangeHook: CollectionBeforeChangeHook = async ({ originalDoc, // original document }) => { return data; // Return data to either create or update a document with -} +}; ``` ### afterChange @@ -122,7 +125,7 @@ const beforeChangeHook: CollectionBeforeChangeHook = async ({ After a document is created or updated, the `afterChange` hook runs. This hook is helpful to recalculate statistics such as total sales within a global, syncing user profile changes to a CRM, and more. ```ts -import { CollectionAfterChangeHook } from 'payload/types'; +import { CollectionAfterChangeHook } from "payload/types"; const afterChangeHook: CollectionAfterChangeHook = async ({ doc, // full document data @@ -131,7 +134,7 @@ const afterChangeHook: CollectionAfterChangeHook = async ({ operation, // name of the operation ie. 'create', 'update' }) => { return doc; -} +}; ``` ### beforeRead @@ -139,7 +142,7 @@ const afterChangeHook: CollectionAfterChangeHook = async ({ Runs before `find` and `findByID` operations are transformed for output by `afterRead`. This hook fires before hidden fields are removed and before localized fields are flattened into the requested locale. Using this Hook will provide you with all locales and all hidden fields via the `doc` argument. ```ts -import { CollectionBeforeReadHook } from 'payload/types'; +import { CollectionBeforeReadHook } from "payload/types"; const beforeReadHook: CollectionBeforeReadHook = async ({ doc, // full document data @@ -147,7 +150,7 @@ const beforeReadHook: CollectionBeforeReadHook = async ({ query, // JSON formatted query }) => { return doc; -} +}; ``` ### afterRead @@ -155,7 +158,7 @@ const beforeReadHook: CollectionBeforeReadHook = async ({ Runs as the last step before documents are returned. Flattens locales, hides protected fields, and removes fields that users do not have access to. ```ts -import { CollectionAfterReadHook } from 'payload/types'; +import { CollectionAfterReadHook } from "payload/types"; const afterReadHook: CollectionAfterReadHook = async ({ doc, // full document data @@ -164,7 +167,7 @@ const afterReadHook: CollectionAfterReadHook = async ({ findMany, // boolean to denote if this hook is running against finding one, or finding many }) => { return doc; -} +}; ``` ### beforeDelete @@ -194,19 +197,37 @@ const afterDeleteHook: CollectionAfterDeleteHook = async ({ }) => {...} ``` +### afterOperation + +The `afterOperation` hook can be used to modify the result of operations or execute side-effects that run after an operation has completed. + +Available Collection operations include `create`, `find`, `findByID`, `update`, `updateByID`, `delete`, `deleteByID`, `login`, `refresh`, and `forgotPassword`. + +```ts +import { CollectionAfterOperationHook } from "payload/types"; + +const afterOperationHook: CollectionAfterOperationHook = async ({ + args, // arguments passed into the operation + operation, // name of the operation + result, // the result of the operation, before modifications +}) => { + return result; // return modified result as necessary +}; +``` + ### beforeLogin For auth-enabled Collections, this hook runs during `login` operations where a user with the provided credentials exist, but before a token is generated and added to the response. You can optionally modify the user that is returned, or throw an error in order to deny the login operation. ```ts -import { CollectionBeforeLoginHook } from 'payload/types'; +import { CollectionBeforeLoginHook } from "payload/types"; const beforeLoginHook: CollectionBeforeLoginHook = async ({ req, // full express request user, // user being logged in }) => { return user; -} +}; ``` ### afterLogin @@ -267,7 +288,7 @@ const afterMeHook: CollectionAfterMeHook = async ({ For auth-enabled Collections, this hook runs after successful `forgotPassword` operations. Returned values are discarded. ```ts -import { CollectionAfterForgotPasswordHook } from 'payload/types'; +import { CollectionAfterForgotPasswordHook } from "payload/types"; const afterLoginHook: CollectionAfterForgotPasswordHook = async ({ req, // full express request @@ -275,7 +296,7 @@ const afterLoginHook: CollectionAfterForgotPasswordHook = async ({ token, // user token }) => { return user; -} +}; ``` ## TypeScript @@ -298,5 +319,5 @@ import type { CollectionAfterRefreshHook, CollectionAfterMeHook, CollectionAfterForgotPasswordHook, -} from 'payload/types'; +} from "payload/types"; ``` diff --git a/src/auth/operations/forgotPassword.ts b/src/auth/operations/forgotPassword.ts index 9fdf665f588..823e7b016b8 100644 --- a/src/auth/operations/forgotPassword.ts +++ b/src/auth/operations/forgotPassword.ts @@ -3,6 +3,7 @@ import { Document } from 'mongoose'; import { APIError } from '../../errors'; import { PayloadRequest } from '../../express/types'; import { Collection } from '../../collections/config/types'; +import { buildAfterOperation } from '../../collections/operations/utils'; export type Arguments = { collection: Collection @@ -128,6 +129,16 @@ async function forgotPassword(incomingArgs: Arguments): Promise { await hook({ args, context: req.context }); }, Promise.resolve()); + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + token = await buildAfterOperation({ + operation: 'forgotPassword', + args, + result: token, + }); + return token; } diff --git a/src/auth/operations/login.ts b/src/auth/operations/login.ts index 19b30d12830..7ab452a3514 100644 --- a/src/auth/operations/login.ts +++ b/src/auth/operations/login.ts @@ -10,6 +10,7 @@ import { User } from '../types'; import { Collection } from '../../collections/config/types'; import { afterRead } from '../../fields/hooks/afterRead'; import unlock from './unlock'; +import { buildAfterOperation } from '../../collections/operations/utils'; import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts'; import { authenticateLocalStrategy } from '../strategies/local/authenticate'; import { getFieldsToSign } from './getFieldsToSign'; @@ -206,15 +207,28 @@ async function login( }) || user; }, Promise.resolve()); - // ///////////////////////////////////// - // Return results - // ///////////////////////////////////// - return { + let result: Result & { user: GeneratedTypes['collections'][TSlug] } = { token, user, exp: (jwt.decode(token) as jwt.JwtPayload).exp, }; + + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'login', + args, + result, + }); + + // ///////////////////////////////////// + // Return results + // ///////////////////////////////////// + + return result; } export default login; diff --git a/src/auth/operations/refresh.ts b/src/auth/operations/refresh.ts index aef6896606b..6ae63854ed3 100644 --- a/src/auth/operations/refresh.ts +++ b/src/auth/operations/refresh.ts @@ -1,11 +1,12 @@ +import url from 'url'; import jwt from 'jsonwebtoken'; import { Response } from 'express'; -import url from 'url'; import { Collection, BeforeOperationHook } from '../../collections/config/types'; import { Forbidden } from '../../errors'; import getCookieExpiration from '../../utilities/getCookieExpiration'; import { Document } from '../../types'; import { PayloadRequest } from '../../express/types'; +import { buildAfterOperation } from '../../collections/operations/utils'; import { getFieldsToSign } from './getFieldsToSign'; export type Result = { @@ -97,7 +98,7 @@ async function refresh(incomingArgs: Arguments): Promise { args.res.cookie(`${config.cookiePrefix}-token`, refreshedToken, cookieOptions); } - let response: Result = { + let result: Result = { user, refreshedToken, exp, @@ -110,20 +111,31 @@ async function refresh(incomingArgs: Arguments): Promise { await collectionConfig.hooks.afterRefresh.reduce(async (priorHook, hook) => { await priorHook; - response = (await hook({ + result = (await hook({ req: args.req, res: args.res, exp, token: refreshedToken, context: args.req.context, - })) || response; + })) || result; }, Promise.resolve()); + + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'refresh', + args, + result, + }); + // ///////////////////////////////////// // Return results // ///////////////////////////////////// - return response; + return result; } export default refresh; diff --git a/src/collections/config/defaults.ts b/src/collections/config/defaults.ts index fde30c9ecad..7972c72c36a 100644 --- a/src/collections/config/defaults.ts +++ b/src/collections/config/defaults.ts @@ -29,6 +29,7 @@ export const defaults = { afterRead: [], beforeDelete: [], afterDelete: [], + afterOperation: [], beforeLogin: [], afterLogin: [], afterLogout: [], diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 326c4dc7d3e..aafa79c5392 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -98,6 +98,7 @@ const collectionSchema = joi.object().keys({ afterRead: joi.array().items(joi.func()), beforeDelete: joi.array().items(joi.func()), afterDelete: joi.array().items(joi.func()), + afterOperation: joi.array().items(joi.func()), beforeLogin: joi.array().items(joi.func()), afterLogin: joi.array().items(joi.func()), afterLogout: joi.array().items(joi.func()), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 46f6fd459f2..043c85eb66c 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -12,6 +12,7 @@ import { IncomingUploadType, Upload } from '../../uploads/types'; import { IncomingCollectionVersions, SanitizedCollectionVersions } from '../../versions/types'; import { BuildQueryArgs } from '../../mongoose/buildQuery'; import { CustomPreviewButtonProps, CustomPublishButtonProps, CustomSaveButtonProps, CustomSaveDraftButtonProps } from '../../admin/components/elements/types'; +import { AfterOperationArg, AfterOperationMap } from '../operations/utils'; import type { Props as ListProps } from '../../admin/components/views/collections/List/types'; import type { Props as EditProps } from '../../admin/components/views/collections/Edit/types'; @@ -123,6 +124,13 @@ export type AfterDeleteHook = (args: { context: RequestContext; }) => any; + +export type AfterOperationHook< + T extends TypeWithID = any, +> = ( + arg: AfterOperationArg, + ) => Promise[keyof AfterOperationMap]>>; + export type AfterErrorHook = (err: Error, res: unknown, context: RequestContext) => { response: any, status: number } | void; export type BeforeLoginHook = (args: { @@ -314,6 +322,7 @@ export type CollectionConfig = { afterMe?: AfterMeHook[]; afterRefresh?: AfterRefreshHook[]; afterForgotPassword?: AfterForgotPasswordHook[]; + afterOperation?: AfterOperationHook[]; }; /** * Custom rest api endpoints diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 4dad449ecc2..1e29bb53920 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -22,11 +22,14 @@ import { afterRead } from '../../fields/hooks/afterRead'; import { generateFileData } from '../../uploads/generateFileData'; import { saveVersion } from '../../versions/saveVersion'; import { mapAsync } from '../../utilities/mapAsync'; +import { buildAfterOperation } from './utils'; import { registerLocalStrategy } from '../../auth/strategies/local/register'; const unlinkFile = promisify(fs.unlink); -export type Arguments = { +export type CreateUpdateType = { [field: string | number | symbol]: unknown } + +export type Arguments = { collection: Collection req: PayloadRequest depth?: number @@ -318,6 +321,16 @@ async function create( }) || result; }, Promise.resolve()); + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'create', + args, + result, + }); + // Remove temp files if enabled, as express-fileupload does not do this automatically if (config.upload?.useTempFiles && collectionConfig.upload) { const { files } = req; diff --git a/src/collections/operations/delete.ts b/src/collections/operations/delete.ts index 951c4e306ba..551fbd1570a 100644 --- a/src/collections/operations/delete.ts +++ b/src/collections/operations/delete.ts @@ -10,6 +10,7 @@ import { Where } from '../../types'; import { afterRead } from '../../fields/hooks/afterRead'; import { deleteCollectionVersions } from '../../versions/deleteCollectionVersions'; import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles'; +import { buildAfterOperation } from './utils'; export type Arguments = { depth?: number @@ -212,10 +213,22 @@ async function deleteOperation `collection-${collectionConfig.slug}-${id}`) } }); - return { + let result = { docs: awaitedDocs.filter(Boolean), errors, }; + + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'delete', + args, + result, + }); + + return result; } export default deleteOperation; diff --git a/src/collections/operations/deleteByID.ts b/src/collections/operations/deleteByID.ts index bc7c895c33a..55c085464d6 100644 --- a/src/collections/operations/deleteByID.ts +++ b/src/collections/operations/deleteByID.ts @@ -4,11 +4,12 @@ import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import { NotFound, Forbidden } from '../../errors'; import executeAccess from '../../auth/executeAccess'; import { BeforeOperationHook, Collection } from '../config/types'; -import { Document, Where } from '../../types'; +import { Document } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; import { afterRead } from '../../fields/hooks/afterRead'; import { deleteCollectionVersions } from '../../versions/deleteCollectionVersions'; import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles'; +import { buildAfterOperation } from './utils'; export type Arguments = { depth?: number @@ -174,6 +175,16 @@ async function deleteByID(inc result = await hook({ req, id, doc: result, context: req.context }) || result; }, Promise.resolve()); + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'deleteByID', + args, + result, + }); + // ///////////////////////////////////// // 8. Return results // ///////////////////////////////////// diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 750e45eec57..ce7f847f5d3 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -9,6 +9,7 @@ import { buildSortParam } from '../../mongoose/buildSortParam'; import { AccessResult } from '../../config/types'; import { afterRead } from '../../fields/hooks/afterRead'; import { queryDrafts } from '../../versions/drafts/queryDrafts'; +import { buildAfterOperation } from './utils'; export type Arguments = { collection: Collection @@ -226,6 +227,16 @@ async function find>( })), }; + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'find', + args, + result, + }); + // ///////////////////////////////////// // Return results // ///////////////////////////////////// diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts index e1ff5e28d32..ec71f23bed7 100644 --- a/src/collections/operations/findByID.ts +++ b/src/collections/operations/findByID.ts @@ -7,6 +7,7 @@ import { NotFound } from '../../errors'; import executeAccess from '../../auth/executeAccess'; import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable'; import { afterRead } from '../../fields/hooks/afterRead'; +import { buildAfterOperation } from './utils'; export type Arguments = { collection: Collection @@ -173,6 +174,16 @@ async function findByID( }) || result; }, Promise.resolve()); + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'findByID', + args, + result, + }); + // ///////////////////////////////////// // Return results // ///////////////////////////////////// diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index fd56f5752e1..b11d7e61607 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -18,8 +18,10 @@ import { AccessResult } from '../../config/types'; import { queryDrafts } from '../../versions/drafts/queryDrafts'; import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles'; import { unlinkTempFiles } from '../../uploads/unlinkTempFiles'; +import { buildAfterOperation } from './utils'; +import { CreateUpdateType } from './create'; -export type Arguments = { +export type Arguments = { collection: Collection req: PayloadRequest where: Where @@ -350,10 +352,22 @@ async function update( const awaitedDocs = await Promise.all(promises); - return { + let result = { docs: awaitedDocs.filter(Boolean), errors, }; + + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'update', + args, + result, + }); + + return result; } export default update; diff --git a/src/collections/operations/updateByID.ts b/src/collections/operations/updateByID.ts index 542e133663f..41c27956e0c 100644 --- a/src/collections/operations/updateByID.ts +++ b/src/collections/operations/updateByID.ts @@ -18,6 +18,7 @@ import { generateFileData } from '../../uploads/generateFileData'; import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion'; import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles'; import { unlinkTempFiles } from '../../uploads/unlinkTempFiles'; +import { buildAfterOperation } from './utils'; import { generatePasswordSaltHash } from '../../auth/strategies/local/generatePasswordSaltHash'; export type Arguments = { @@ -340,6 +341,16 @@ async function updateByID( }) || result; }, Promise.resolve()); + // ///////////////////////////////////// + // afterOperation - Collection + // ///////////////////////////////////// + + result = await buildAfterOperation({ + operation: 'updateByID', + args, + result, + }); + await unlinkTempFiles({ req, config, diff --git a/src/collections/operations/utils.ts b/src/collections/operations/utils.ts new file mode 100644 index 00000000000..d27974c77d6 --- /dev/null +++ b/src/collections/operations/utils.ts @@ -0,0 +1,71 @@ +import find from './find'; +import update from './update'; +import deleteOperation from './delete'; +import create from './create'; +import login from '../../auth/operations/login'; +import refresh from '../../auth/operations/refresh'; +import findByID from './findByID'; +import updateByID from './updateByID'; +import deleteByID from './deleteByID'; +import { AfterOperationHook, TypeWithID } from '../config/types'; +import forgotPassword from '../../auth/operations/forgotPassword'; + +export type AfterOperationMap< + T extends TypeWithID, +> = { + create: typeof create, // todo: pass correct generic + find: typeof find, + findByID: typeof findByID, + update: typeof update, // todo: pass correct generic + updateByID: typeof updateByID, // todo: pass correct generic + delete: typeof deleteOperation, // todo: pass correct generic + deleteByID: typeof deleteByID, // todo: pass correct generic + login: typeof login, + refresh: typeof refresh, + forgotPassword: typeof forgotPassword, +} +export type AfterOperationArg = + | { operation: 'create'; result: Awaited['create']>>, args: Parameters['create']>[0] } + | { operation: 'find'; result: Awaited['find']>>, args: Parameters['find']>[0] } + | { operation: 'findByID'; result: Awaited['findByID']>>, args: Parameters['findByID']>[0] } + | { operation: 'update'; result: Awaited['update']>>, args: Parameters['update']>[0] } + | { operation: 'updateByID'; result: Awaited['updateByID']>>, args: Parameters['updateByID']>[0] } + | { operation: 'delete'; result: Awaited['delete']>>, args: Parameters['delete']>[0] } + | { operation: 'deleteByID'; result: Awaited['deleteByID']>>, args: Parameters['deleteByID']>[0] } + | { operation: 'login'; result: Awaited['login']>>, args: Parameters['login']>[0] } + | { operation: 'refresh'; result: Awaited['refresh']>>, args: Parameters['refresh']>[0] } + | { operation: 'forgotPassword'; result: Awaited['forgotPassword']>>, args: Parameters['forgotPassword']>[0] }; + +// export type AfterOperationHook = typeof buildAfterOperation; + +export const buildAfterOperation = async < + T extends TypeWithID = any, + O extends keyof AfterOperationMap = keyof AfterOperationMap +> +( + operationArgs: AfterOperationArg & { operation: O }, +): Promise[O]>>> => { + const { + operation, + args, + result, + } = operationArgs; + + let newResult = result; + + await args.collection.config.hooks.afterOperation.reduce(async (priorHook, hook: AfterOperationHook) => { + await priorHook; + + const hookResult = await hook({ + operation, + args, + result: newResult, + } as AfterOperationArg); + + if (hookResult !== undefined) { + newResult = hookResult; + } + }, Promise.resolve()); + + return newResult; +}; diff --git a/test/hooks/collections/AfterOperation/index.ts b/test/hooks/collections/AfterOperation/index.ts new file mode 100644 index 00000000000..cad742b32ca --- /dev/null +++ b/test/hooks/collections/AfterOperation/index.ts @@ -0,0 +1,71 @@ +import { AfterOperationHook, CollectionConfig } from '../../../../src/collections/config/types'; +import { AfterOperation } from '../../payload-types'; + +export const afterOperationSlug = 'afterOperation'; + +const AfterOperation: CollectionConfig = { + slug: afterOperationSlug, + hooks: { + // beforeRead: [(operation) => operation.doc], + afterOperation: [ + async ({ result, operation }) => { + if (operation === 'create') { + if ('docs' in result) { + return { + ...result, + docs: result.docs?.map((doc) => ({ + ...doc, + title: 'Title created', + })), + }; + } + + return { ...result, title: 'Title created' }; + } + + if (operation === 'find') { + // only modify the first doc for `find` operations + // this is so we can test against the other operations + return { + ...result, + docs: result.docs?.map((doc, index) => (index === 0 ? { + ...doc, + title: 'Title read', + } : doc)), + }; + } + + if (operation === 'findByID') { + return { ...result, title: 'Title read' }; + } + + if (operation === 'update') { + if ('docs' in result) { + return { + ...result, + docs: result.docs?.map((doc) => ({ + ...doc, + title: 'Title updated', + })), + }; + } + } + + if (operation === 'updateByID') { + return { ...result, title: 'Title updated' }; + } + + return result; + }, + ] as AfterOperationHook[], + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + ], +}; + +export default AfterOperation; diff --git a/test/hooks/config.ts b/test/hooks/config.ts index d36e9ce3640..cca0e4b3b45 100644 --- a/test/hooks/config.ts +++ b/test/hooks/config.ts @@ -4,11 +4,13 @@ import Hooks, { hooksSlug } from './collections/Hook'; import NestedAfterReadHooks from './collections/NestedAfterReadHooks'; import ChainingHooks from './collections/ChainingHooks'; import Relations from './collections/Relations'; +import AfterOperation from './collections/AfterOperation'; import Users, { seedHooksUsers } from './collections/Users'; import ContextHooks from './collections/ContextHooks'; export default buildConfigWithDefaults({ collections: [ + AfterOperation, ContextHooks, TransformHooks, Hooks, diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index d65909b9f2e..d0fbf984486 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import { initPayloadTest } from '../helpers/configHelpers'; -import config from './config'; +import configPromise from './config'; import payload from '../../src'; import { RESTClient } from '../helpers/rest'; import { transformSlug } from './collections/Transform'; @@ -8,10 +8,10 @@ import { hooksSlug } from './collections/Hook'; import { chainingHooksSlug } from './collections/ChainingHooks'; import { generatedAfterReadText, nestedAfterReadHooksSlug } from './collections/NestedAfterReadHooks'; import { relationsSlug } from './collections/Relations'; -import type { NestedAfterReadHook } from './payload-types'; import { hooksUsersSlug } from './collections/Users'; import { devUser, regularUser } from '../credentials'; import { AuthenticationError } from '../../src/errors'; +import { afterOperationSlug } from './collections/AfterOperation'; import { contextHooksSlug } from './collections/ContextHooks'; let client: RESTClient; @@ -20,6 +20,7 @@ let apiUrl; describe('Hooks', () => { beforeAll(async () => { const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } }); + const config = await configPromise; client = new RESTClient(config, { serverURL, defaultSlug: transformSlug }); apiUrl = `${serverURL}/api`; }); @@ -74,7 +75,7 @@ describe('Hooks', () => { }); it('should save data generated with afterRead hooks in nested field structures', async () => { - const document = await payload.create({ + const document = await payload.create({ collection: nestedAfterReadHooksSlug, data: { text: 'ok', @@ -155,6 +156,62 @@ describe('Hooks', () => { expect(retrievedDocs[0].text).toEqual('ok!!'); }); + it('should execute collection afterOperation hook', async () => { + const [doc1, doc2] = await Promise.all([ + await payload.create({ + collection: afterOperationSlug, + data: { + title: 'Title', + }, + }), + await payload.create({ + collection: afterOperationSlug, + data: { + title: 'Title', + }, + }), + ]); + + expect(doc1.title === 'Title created').toBeTruthy(); + expect(doc2.title === 'Title created').toBeTruthy(); + + const findResult = await payload.find({ + collection: afterOperationSlug, + }); + + expect(findResult.docs).toHaveLength(2); + expect(findResult.docs[0].title === 'Title read').toBeTruthy(); + expect(findResult.docs[1].title === 'Title').toBeTruthy(); + + const [updatedDoc1, updatedDoc2] = await Promise.all([ + await payload.update({ + collection: afterOperationSlug, + id: doc1.id, + data: { + title: 'Title', + }, + }), + await payload.update({ + collection: afterOperationSlug, + id: doc2.id, + data: { + title: 'Title', + }, + }), + ]); + + expect(updatedDoc1.title === 'Title updated').toBeTruthy(); + expect(updatedDoc2.title === 'Title updated').toBeTruthy(); + + const findResult2 = await payload.find({ + collection: afterOperationSlug, + }); + + expect(findResult2.docs).toHaveLength(2); + expect(findResult2.docs[0].title === 'Title read').toBeTruthy(); + expect(findResult2.docs[1].title === 'Title').toBeTruthy(); + }); + it('should pass context from beforeChange to afterChange', async () => { const document = await payload.create({ collection: contextHooksSlug, diff --git a/test/hooks/payload-types.ts b/test/hooks/payload-types.ts index f5582c89f39..4d9a73c4877 100644 --- a/test/hooks/payload-types.ts +++ b/test/hooks/payload-types.ts @@ -5,11 +5,24 @@ * and re-run `payload generate:types` to regenerate this file. */ -export interface Config {} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "transforms". - */ +export interface Config { + collections: { + afterOperation: AfterOperation; + transforms: Transform; + hooks: Hook; + 'nested-after-read-hooks': NestedAfterReadHook; + 'chaining-hooks': ChainingHook; + relations: Relation; + 'hooks-users': HooksUser; + }; + globals: {}; +} +export interface AfterOperation { + id: string; + title: string; + updatedAt: string; + createdAt: string; +} export interface Transform { id: string; /** @@ -22,13 +35,9 @@ export interface Transform { * @maxItems 2 */ localizedTransform?: [number, number]; - createdAt: string; updatedAt: string; + createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "hooks". - */ export interface Hook { id: string; fieldBeforeValidate?: boolean; @@ -40,53 +49,48 @@ export interface Hook { collectionAfterChange?: boolean; collectionBeforeRead?: boolean; collectionAfterRead?: boolean; - createdAt: string; updatedAt: string; + createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "nested-after-read-hooks". - */ export interface NestedAfterReadHook { id: string; text?: string; - group: { - array: { + group?: { + array?: { input?: string; afterRead?: string; shouldPopulate?: string | Relation; id?: string; }[]; - subGroup: { + subGroup?: { afterRead?: string; shouldPopulate?: string | Relation; }; }; - createdAt: string; updatedAt: string; + createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "relations". - */ export interface Relation { id: string; title: string; + updatedAt: string; createdAt: string; +} +export interface ChainingHook { + id: string; + text?: string; updatedAt: string; + createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "hooks-users". - */ export interface HooksUser { id: string; roles: ('admin' | 'user')[]; + updatedAt: string; + createdAt: string; email?: string; resetPasswordToken?: string; resetPasswordExpiration?: string; loginAttempts?: number; lockUntil?: string; - createdAt: string; - updatedAt: string; + password?: string; } diff --git a/types.d.ts b/types.d.ts index 70adee2f2f9..12c2da5cbf1 100644 --- a/types.d.ts +++ b/types.d.ts @@ -8,6 +8,7 @@ export { export { CollectionConfig, SanitizedCollectionConfig, + AfterOperationHook as CollectionAfterOperationHook, BeforeOperationHook as CollectionBeforeOperationHook, BeforeValidateHook as CollectionBeforeValidateHook, BeforeChangeHook as CollectionBeforeChangeHook, From c154eb7e2baec44688859e0902b1ea949868b859 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Tue, 15 Aug 2023 09:19:18 -0400 Subject: [PATCH 03/52] chore: remove swc version pin (#3179) --- src/bin/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/index.ts b/src/bin/index.ts index 74ae6ae29e4..3806d7825ae 100755 --- a/src/bin/index.ts +++ b/src/bin/index.ts @@ -16,7 +16,7 @@ const swcOptions = { tsx: true, }, paths: undefined, - baseUrl: __dirname, + baseUrl: path.resolve(), }, module: { type: 'commonjs', From fdfdfc83f36a958971f8e4e4f9f5e51560cb26e0 Mon Sep 17 00:00:00 2001 From: Alessio Gravili <70709113+AlessioGr@users.noreply.github.com> Date: Tue, 15 Aug 2023 19:22:57 +0200 Subject: [PATCH 04/52] fix: WhereBuilder component does not accept all valid Where queries (#3087) * chore: add jsDocs for ListControls * chore: add jsDocs for ListView * chore: add jsDocs for WhereBuilder * chore: add comment * chore: remove unnecessary console log * chore: improve operator type * fix: transform where queries which aren't necessarily incorrect, and improve their validation * chore: add type to import * fix: do not merge existing old query params with new ones if the existing old ones got transformed and are not valid, as that would cause duplicates * chore: sort imports and remove extra validation * fix: transformWhereQuery logic * chore: add back extra validation * chore: add e2e tests --- .../elements/ListControls/index.tsx | 5 ++ .../elements/WhereBuilder/index.tsx | 31 +++++++++-- .../WhereBuilder/transformWhereQuery.ts | 51 +++++++++++++++++++ .../WhereBuilder/validateWhereQuery.ts | 32 +++++++++++- .../views/collections/List/index.tsx | 6 +++ src/types/index.ts | 31 ++++++----- test/admin/e2e.spec.ts | 19 +++++++ 7 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 src/admin/components/elements/WhereBuilder/transformWhereQuery.ts diff --git a/src/admin/components/elements/ListControls/index.tsx b/src/admin/components/elements/ListControls/index.tsx index 7f71276ef51..ecfeffb6fad 100644 --- a/src/admin/components/elements/ListControls/index.tsx +++ b/src/admin/components/elements/ListControls/index.tsx @@ -38,6 +38,11 @@ const getUseAsTitle = (collection: SanitizedCollectionConfig) => { return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle); }; +/** + * The ListControls component is used to render the controls (search, filter, where) + * for a collection's list view. You can find those directly above the table which lists + * the collection's documents. + */ const ListControls: React.FC = (props) => { const { collection, diff --git a/src/admin/components/elements/WhereBuilder/index.tsx b/src/admin/components/elements/WhereBuilder/index.tsx index d215c42b77b..3bf71a4918d 100644 --- a/src/admin/components/elements/WhereBuilder/index.tsx +++ b/src/admin/components/elements/WhereBuilder/index.tsx @@ -13,6 +13,7 @@ import { useSearchParams } from '../../utilities/SearchParams'; import validateWhereQuery from './validateWhereQuery'; import { Where } from '../../../../types'; import { getTranslation } from '../../../../utilities/getTranslation'; +import { transformWhereQuery } from './transformWhereQuery'; import './index.scss'; @@ -43,6 +44,10 @@ const reduceFields = (fields, i18n) => flattenTopLevelFields(fields).reduce((red return reduced; }, []); +/** + * The WhereBuilder component is used to render the filter controls for a collection's list view. + * It is part of the {@link ListControls} component which is used to render the controls (search, filter, where). + */ const WhereBuilder: React.FC = (props) => { const { collection, @@ -59,16 +64,30 @@ const WhereBuilder: React.FC = (props) => { const params = useSearchParams(); const { t, i18n } = useTranslation('general'); + // This handles initializing the where conditions from the search query (URL). That way, if you pass in + // query params to the URL, the where conditions will be initialized from those and displayed in the UI. + // Example: /admin/collections/posts?where[or][0][and][0][text][equals]=example%20post const [conditions, dispatchConditions] = useReducer(reducer, params.where, (whereFromSearch) => { - if (modifySearchQuery && validateWhereQuery(whereFromSearch)) { - return whereFromSearch.or; - } + if (modifySearchQuery && whereFromSearch) { + if (validateWhereQuery(whereFromSearch)) { + return whereFromSearch.or; + } + + // Transform the where query to be in the right format. This will transform something simple like [text][equals]=example%20post to the right format + const transformedWhere = transformWhereQuery(whereFromSearch); + + if (validateWhereQuery(transformedWhere)) { + return transformedWhere.or; + } + console.warn('Invalid where query in URL. Ignoring.'); + } return []; }); const [reducedFields] = useState(() => reduceFields(collection.fields, i18n)); + // This handles updating the search query (URL) when the where conditions change useThrottledEffect(() => { const currentParams = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 10 }) as { where: Where }; @@ -83,8 +102,11 @@ const WhereBuilder: React.FC = (props) => { ]; }, []) : []; + const hasNewWhereConditions = conditions.length > 0; + + const newWhereQuery = { - ...typeof currentParams?.where === 'object' ? currentParams.where : {}, + ...typeof currentParams?.where === 'object' && (validateWhereQuery(currentParams?.where) || !hasNewWhereConditions) ? currentParams.where : {}, or: [ ...conditions, ...paramsToKeep, @@ -94,7 +116,6 @@ const WhereBuilder: React.FC = (props) => { if (handleChange) handleChange(newWhereQuery as Where); const hasExistingConditions = typeof currentParams?.where === 'object' && 'or' in currentParams.where; - const hasNewWhereConditions = conditions.length > 0; if (modifySearchQuery && ((hasExistingConditions && !hasNewWhereConditions) || hasNewWhereConditions)) { history.replace({ diff --git a/src/admin/components/elements/WhereBuilder/transformWhereQuery.ts b/src/admin/components/elements/WhereBuilder/transformWhereQuery.ts new file mode 100644 index 00000000000..1edb9a8bcf4 --- /dev/null +++ b/src/admin/components/elements/WhereBuilder/transformWhereQuery.ts @@ -0,0 +1,51 @@ +import type { Where } from '../../../../types'; + +/** + * Something like [or][0][and][0][text][equals]=example%20post will work and pass through the validateWhereQuery check. + * However, something like [text][equals]=example%20post will not work and will fail the validateWhereQuery check, + * even though it is a valid Where query. This needs to be transformed here. + */ +export const transformWhereQuery = (whereQuery): Where => { + if (!whereQuery) { + return {}; + } + // Check if 'whereQuery' has 'or' field but no 'and'. This is the case for "correct" queries + if (whereQuery.or && !whereQuery.and) { + return { + or: whereQuery.or.map((query) => { + // ...but if the or query does not have an and, we need to add it + if(!query.and) { + return { + and: [query] + } + } + return query; + }), + }; + } + + // Check if 'whereQuery' has 'and' field but no 'or'. + if (whereQuery.and && !whereQuery.or) { + return { + or: [ + { + and: whereQuery.and, + }, + ], + }; + } + + // Check if 'whereQuery' has neither 'or' nor 'and'. + if (!whereQuery.or && !whereQuery.and) { + return { + or: [ + { + and: [whereQuery], // top-level siblings are considered 'and' + }, + ], + }; + } + + // If 'whereQuery' has 'or' and 'and', just return it as it is. + return whereQuery; +}; diff --git a/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts b/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts index f0f62656fce..205756ad8c7 100644 --- a/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts +++ b/src/admin/components/elements/WhereBuilder/validateWhereQuery.ts @@ -1,8 +1,36 @@ -import { Where } from '../../../../types'; +import { type Operator, type Where, validOperators } from '../../../../types'; const validateWhereQuery = (whereQuery): whereQuery is Where => { if (whereQuery?.or?.length > 0 && whereQuery?.or?.[0]?.and && whereQuery?.or?.[0]?.and?.length > 0) { - return true; + // At this point we know that the whereQuery has 'or' and 'and' fields, + // now let's check the structure and content of these fields. + + const isValid = whereQuery.or.every((orQuery) => { + if (orQuery.and && Array.isArray(orQuery.and)) { + return orQuery.and.every((andQuery) => { + if (typeof andQuery !== 'object') { + return false; + } + const andKeys = Object.keys(andQuery); + // If there are no keys, it's not a valid WhereField. + if (andKeys.length === 0) { + return false; + } + // eslint-disable-next-line no-restricted-syntax + for (const key of andKeys) { + const operator = Object.keys(andQuery[key])[0]; + // Check if the key is a valid Operator. + if (!operator || !validOperators.includes(operator as Operator)) { + return false; + } + } + return true; + }); + } + return false; + }); + + return isValid; } return false; diff --git a/src/admin/components/views/collections/List/index.tsx b/src/admin/components/views/collections/List/index.tsx index 6b03c653483..10f9b71c0cc 100644 --- a/src/admin/components/views/collections/List/index.tsx +++ b/src/admin/components/views/collections/List/index.tsx @@ -16,6 +16,12 @@ import { useSearchParams } from '../../../utilities/SearchParams'; import { TableColumnsProvider } from '../../../elements/TableColumns'; import type { Field } from '../../../../../fields/config/types'; +/** + * The ListView component is table which lists the collection's documents. + * The default list view can be found at the {@link DefaultList} component. + * Users can also create pass their own custom list view component instead + * of using the default one. + */ const ListView: React.FC = (props) => { const { collection, diff --git a/src/types/index.ts b/src/types/index.ts index be11631972b..b5a56a4080f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,20 +4,23 @@ import { FileData } from '../uploads/types'; export { PayloadRequest } from '../express/types'; -export type Operator = - | 'equals' - | 'contains' - | 'not_equals' - | 'in' - | 'all' - | 'not_in' - | 'exists' - | 'greater_than' - | 'greater_than_equal' - | 'less_than' - | 'less_than_equal' - | 'like' - | 'near'; +export const validOperators = [ + 'equals', + 'contains', + 'not_equals', + 'in', + 'all', + 'not_in', + 'exists', + 'greater_than', + 'greater_than_equal', + 'less_than', + 'less_than_equal', + 'like', + 'near', +] as const; + +export type Operator = typeof validOperators[number]; export type WhereField = { [key in Operator]?: unknown; diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index fb6ac90b28a..ca01a801866 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -394,6 +394,25 @@ describe('admin', () => { await page.locator('.condition__actions-remove').click(); await expect(page.locator(tableRowLocator)).toHaveCount(2); }); + + test('should accept where query from valid URL where parameter', async () => { + await createPost({ title: 'post1' }); + await createPost({ title: 'post2' }); + await page.goto(`${url.list}?limit=10&page=1&where[or][0][and][0][title][equals]=post1`); + + await expect(page.locator('.react-select--single-value').first()).toContainText('Title en'); + await expect(page.locator(tableRowLocator)).toHaveCount(1); + }); + + test('should accept transformed where query from invalid URL where parameter', async () => { + await createPost({ title: 'post1' }); + await createPost({ title: 'post2' }); + // [title][equals]=post1 should be getting transformed into a valid where[or][0][and][0][title][equals]=post1 + await page.goto(`${url.list}?limit=10&page=1&where[title][equals]=post1`); + + await expect(page.locator('.react-select--single-value').first()).toContainText('Title en'); + await expect(page.locator(tableRowLocator)).toHaveCount(1); + }); }); describe('table columns', () => { From 20b6b29c793713f108f4d9c0904284344e575025 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury <67977755+JessChowdhury@users.noreply.github.com> Date: Tue, 15 Aug 2023 18:48:33 +0100 Subject: [PATCH 05/52] chore(test): adds test to ensure relationship returns over 10 docs (#3181) * chore(test): adds test to ensure relationship returns over 10 docs * chore: remove unnecessary movieDocs variable --- test/relationships/config.ts | 6 +++ test/relationships/int.spec.ts | 60 ++++++++++++++++++++++++++++- test/relationships/payload-types.ts | 1 + 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/test/relationships/config.ts b/test/relationships/config.ts index 231d3421183..556516ffd72 100644 --- a/test/relationships/config.ts +++ b/test/relationships/config.ts @@ -190,6 +190,12 @@ export default buildConfigWithDefaults({ name: 'name', type: 'text', }, + { + name: 'movies', + type: 'relationship', + relationTo: 'movies', + hasMany: true, + }, ], }, ], diff --git a/test/relationships/int.spec.ts b/test/relationships/int.spec.ts index 20302a29565..852daeb366f 100644 --- a/test/relationships/int.spec.ts +++ b/test/relationships/int.spec.ts @@ -1,5 +1,5 @@ -import mongoose from 'mongoose'; import { randomBytes } from 'crypto'; +import mongoose from 'mongoose'; import { initPayloadTest } from '../helpers/configHelpers'; import config, { customIdSlug, chainedRelSlug, defaultAccessRelSlug, slug, relationSlug, customIdNumberSlug } from './config'; import payload from '../../src'; @@ -345,6 +345,64 @@ describe('Relationships', () => { expect(query.docs).toHaveLength(1); }); }); + describe('Multiple Docs', () => { + const movieList = [ + 'Pulp Fiction', + 'Reservoir Dogs', + 'Once Upon a Time in Hollywood', + 'Shrek', + 'Shrek 2', + 'Shrek 3', + 'Scream', + 'The Matrix', + 'The Matrix Reloaded', + 'The Matrix Revolutions', + 'The Matrix Resurrections', + 'The Haunting', + 'The Haunting of Hill House', + 'The Haunting of Bly Manor', + 'Insidious', + ]; + + beforeAll(async () => { + await Promise.all(movieList.map((movie) => { + return payload.create({ + collection: 'movies', + data: { + name: movie, + }, + }); + })); + }); + + it('should return more than 10 docs in relationship', async () => { + const allMovies = await payload.find({ + collection: 'movies', + limit: 20, + }); + + const movieIDs = allMovies.docs.map((doc) => doc.id); + + await payload.create({ + collection: 'directors', + data: { + name: 'Quentin Tarantino', + movies: movieIDs, + }, + }); + + const director = await payload.find({ + collection: 'directors', + where: { + name: { + equals: 'Quentin Tarantino', + }, + }, + }); + + expect(director.docs[0].movies.length).toBeGreaterThan(10); + }); + }); }); }); diff --git a/test/relationships/payload-types.ts b/test/relationships/payload-types.ts index de4c1310a60..3ba0925a93b 100644 --- a/test/relationships/payload-types.ts +++ b/test/relationships/payload-types.ts @@ -85,6 +85,7 @@ export interface Movie { export interface Director { id: string; name?: string; + movies?: Array; updatedAt: string; createdAt: string; } From 7963d04a27888eb5a12d0ab37f2082cd33638abd Mon Sep 17 00:00:00 2001 From: PatrikKozak <35232443+PatrikKozak@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:58:16 -0400 Subject: [PATCH 06/52] fix: passes in height to resizeOptions upload option to allow height resize (#3171) --- src/uploads/generateFileData.ts | 1 + src/uploads/imageResizer.ts | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/uploads/generateFileData.ts b/src/uploads/generateFileData.ts index c2fe72ecd8f..247c9259701 100644 --- a/src/uploads/generateFileData.ts +++ b/src/uploads/generateFileData.ts @@ -115,6 +115,7 @@ export const generateFileData = async ({ fileBuffer = await sharpFile.toBuffer({ resolveWithObject: true }); ({ mime, ext } = await fromBuffer(fileBuffer.data)); // This is getting an incorrect gif height back. fileData.width = fileBuffer.info.width; + fileData.height = fileBuffer.info.height; fileData.filesize = fileBuffer.info.size; // Animated GIFs + WebP aggregate the height from every frame, so we need to use divide by number of pages diff --git a/src/uploads/imageResizer.ts b/src/uploads/imageResizer.ts index 7da8f0a3134..d28d85a7d71 100644 --- a/src/uploads/imageResizer.ts +++ b/src/uploads/imageResizer.ts @@ -106,7 +106,7 @@ const createResult = ( * @returns true if the image needs to be resized, false otherwise */ const needsResize = ( - { width: desiredWidth, height: desiredHeigth, withoutEnlargement, withoutReduction }: ImageSize, + { width: desiredWidth, height: desiredHeight, withoutEnlargement, withoutReduction }: ImageSize, original: ProbedImageSize, ): boolean => { // allow enlargement or prevent reduction (our default is to prevent @@ -115,7 +115,8 @@ const needsResize = ( return true; // needs resize } - const isWidthOrHeightNotDefined = !desiredHeigth || !desiredWidth; + const isWidthOrHeightNotDefined = !desiredHeight || !desiredWidth; + if (isWidthOrHeightNotDefined) { // If with and height are not defined, it means there is a format conversion // and the image needs to be "resized" (transformed). @@ -123,7 +124,7 @@ const needsResize = ( } const hasInsufficientWidth = original.width < desiredWidth; - const hasInsufficientHeight = original.height < desiredHeigth; + const hasInsufficientHeight = original.height < desiredHeight; if (hasInsufficientWidth && hasInsufficientHeight) { // doesn't need resize - prevent enlargement. This should only happen if both width and height are insufficient. // if only one dimension is insufficient and the other is sufficient, resizing needs to happen, as the image From 8d83e059481e471cbca8c6c516ba4d0d656d67e0 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 16 Aug 2023 03:28:19 -0400 Subject: [PATCH 07/52] docs: fixes syntax error in rich-text.mdx that was breaking build --- docs/fields/rich-text.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/fields/rich-text.mdx b/docs/fields/rich-text.mdx index c8518ac0250..1e83c66ade5 100644 --- a/docs/fields/rich-text.mdx +++ b/docs/fields/rich-text.mdx @@ -26,8 +26,7 @@ The Admin component is built on the powerful [`slatejs`](https://docs.slatejs.or Consistent with Payload's goal of making you learn as little of Payload as possible, customizing and using the Rich Text Editor does not involve learning how to develop for a Payload rich text editor. - {" "} - Instead, you can invest your time and effort into learning Slate, an + Instead, you can invest your time and effort into learning Slate, an open-source tool that will allow you to apply your learnings elsewhere as well. From 960fbacd199f61a9244de6d66c6b56b47398652d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Aug 2023 08:16:53 +0000 Subject: [PATCH 08/52] chore(deps): bump semver in /examples/redirects/nextjs Bumps [semver](https://github.com/npm/node-semver) from 7.3.8 to 7.5.4. - [Release notes](https://github.com/npm/node-semver/releases) - [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md) - [Commits](https://github.com/npm/node-semver/compare/v7.3.8...v7.5.4) --- updated-dependencies: - dependency-name: semver dependency-type: indirect ... Signed-off-by: dependabot[bot] --- examples/redirects/nextjs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/redirects/nextjs/yarn.lock b/examples/redirects/nextjs/yarn.lock index 9cd3e904410..6b461cb448a 100644 --- a/examples/redirects/nextjs/yarn.lock +++ b/examples/redirects/nextjs/yarn.lock @@ -1596,9 +1596,9 @@ scheduler@^0.23.0: loose-envify "^1.1.0" semver@^7.3.7: - version "7.3.8" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" - integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== dependencies: lru-cache "^6.0.0" From 87cc01a7d756b2e713808650b3404f6e43ec9717 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Aug 2023 08:16:55 +0000 Subject: [PATCH 09/52] chore(deps): bump word-wrap in /examples/redirects/nextjs Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5. - [Release notes](https://github.com/jonschlinkert/word-wrap/releases) - [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5) --- updated-dependencies: - dependency-name: word-wrap dependency-type: indirect ... Signed-off-by: dependabot[bot] --- examples/redirects/nextjs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/redirects/nextjs/yarn.lock b/examples/redirects/nextjs/yarn.lock index 9cd3e904410..13ebda3ed37 100644 --- a/examples/redirects/nextjs/yarn.lock +++ b/examples/redirects/nextjs/yarn.lock @@ -1814,9 +1814,9 @@ which@^2.0.1: isexe "^2.0.0" word-wrap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== wrappy@1: version "1.0.2" From 846485388a29ca0db300e9c5a9df26dba4deb231 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Wed, 16 Aug 2023 10:15:27 -0400 Subject: [PATCH 10/52] docs: removes auto-formatting from rich-text.mdx (#3188) --- docs/fields/rich-text.mdx | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/docs/fields/rich-text.mdx b/docs/fields/rich-text.mdx index 1e83c66ade5..775246c1228 100644 --- a/docs/fields/rich-text.mdx +++ b/docs/fields/rich-text.mdx @@ -7,9 +7,7 @@ keywords: rich text, fields, config, configuration, documentation, Content Manag --- - The Rich Text field is a powerful way to allow editors to write dynamic - content. The content is saved as JSON in the database and can be converted - into any format, including HTML, that you need. + The Rich Text field is a powerful way to allow editors to write dynamic content. The content is saved as JSON in the database and can be converted into any format, including HTML, that you need. - - Consistent with Payload's goal of making you learn as little of Payload as - possible, customizing and using the Rich Text Editor does not involve - learning how to develop for a Payload rich text editor. - Instead, you can invest your time and effort into learning Slate, an - open-source tool that will allow you to apply your learnings elsewhere as - well. + Consistent with Payload's goal of making you learn as little of Payload as possible, customizing and using the Rich Text Editor does not involve learning how to develop for a Payload rich text editor. Instead, you can invest your time and effort into learning Slate, an open-source tool that will allow you to apply your learnings elsewhere as well. ### Config @@ -124,13 +116,7 @@ The built-in `relationship` element is a powerful way to reference other Documen Similar to the `relationship` element, the `upload` element is a user-friendly way to reference [Upload-enabled collections](/docs/upload/overview) with a UI specifically designed for media / image-based uploads. - Tip: -
- Collections are automatically allowed to be selected within the Rich Text - relationship and upload elements by default. If you want to disable a - collection from being able to be referenced in Rich Text fields, set the - collection admin options of enableRichTextLink and{" "} - enableRichTextRelationship to false. + Tip:
Collections are automatically allowed to be selected within the Rich Text relationship and upload elements by default. If you want to disable a collection from being able to be referenced in Rich Text fields, set the collection admin options of enableRichTextLink and enableRichTextRelationship to false.
Relationship and Upload elements are populated dynamically into your Rich Text field' content. Within the REST and Local APIs, any present RichText `relationship` or `upload` elements will respect the `depth` option that you pass, and will be populated accordingly. In GraphQL, each `richText` field accepts an argument of `depth` for you to utilize. @@ -306,10 +292,7 @@ const serialize = (children) => ``` - Note: -
- The above example is for how to render to JSX, although for plain HTML the - pattern is similar. Just remove the JSX and return HTML strings instead! + Note:
The above example is for how to render to JSX, although for plain HTML the pattern is similar. Just remove the JSX and return HTML strings instead!
### Built-in SlateJS Plugins From e03a8e6b030e82a17e1cdae5b4032433cf9c75a4 Mon Sep 17 00:00:00 2001 From: Greg Willard Date: Wed, 16 Aug 2023 10:26:17 -0400 Subject: [PATCH 11/52] feat: Improve admin dashboard accessibility (#3053) Co-authored-by: Alessio Gravili --- package.json | 1 + scripts/translateNewKeys.ts | 128 ++++++++++++++++++ .../components/elements/Button/index.tsx | 3 + src/admin/components/elements/Button/types.ts | 1 + src/admin/components/elements/Card/index.scss | 3 +- src/admin/components/elements/Card/index.tsx | 9 +- src/admin/components/elements/Card/types.ts | 4 + .../elements/ColumnSelector/index.tsx | 3 +- .../elements/ListControls/index.tsx | 9 ++ .../components/elements/Logout/index.tsx | 12 +- src/admin/components/elements/Nav/index.tsx | 4 +- src/admin/components/elements/Pill/index.tsx | 8 ++ src/admin/components/elements/Pill/types.ts | 4 + .../components/elements/ReactSelect/types.ts | 1 + .../components/elements/SortColumn/index.tsx | 4 +- .../forms/field-types/Checkbox/Input.tsx | 50 ++++--- .../forms/field-types/Checkbox/index.scss | 79 +++++------ .../components/views/Account/Default.tsx | 2 + .../components/views/Dashboard/Default.tsx | 8 +- .../views/collections/List/Default.tsx | 5 +- .../collections/List/SelectAll/index.scss | 34 ----- .../collections/List/SelectAll/index.tsx | 31 ++--- .../collections/List/SelectRow/index.tsx | 24 +--- src/translations/ar.json | 12 +- src/translations/az.json | 5 + src/translations/bg.json | 12 +- src/translations/cs.json | 22 +-- src/translations/de.json | 10 +- src/translations/en.json | 4 + src/translations/es.json | 8 +- src/translations/fa.json | 8 +- src/translations/fr.json | 8 +- src/translations/hr.json | 8 +- src/translations/hu.json | 22 +-- src/translations/it.json | 8 +- src/translations/ja.json | 30 ++-- src/translations/my.json | 8 +- src/translations/nb.json | 8 +- src/translations/nl.json | 8 +- src/translations/pl.json | 8 +- src/translations/pt.json | 8 +- src/translations/ro.json | 8 +- src/translations/ru.json | 8 +- src/translations/sv.json | 8 +- src/translations/th.json | 8 +- src/translations/tr.json | 8 +- src/translations/translation-schema.json | 12 ++ src/translations/ua.json | 10 +- src/translations/vi.json | 8 +- src/translations/zh.json | 8 +- test/access-control/e2e.spec.ts | 2 +- test/admin/e2e.spec.ts | 24 ++-- test/refresh-permissions/e2e.spec.ts | 2 +- test/versions/e2e.spec.ts | 8 +- 54 files changed, 496 insertions(+), 242 deletions(-) create mode 100644 scripts/translateNewKeys.ts delete mode 100644 src/admin/components/views/collections/List/SelectAll/index.scss diff --git a/package.json b/package.json index aafa79722a3..738b36767f9 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "test:e2e:headed": "cross-env DISABLE_LOGGING=true playwright test --headed", "test:e2e:debug": "cross-env PWDEBUG=1 DISABLE_LOGGING=true playwright test", "test:components": "cross-env jest --config=jest.components.config.js", + "translateNewKeys": "ts-node -T ./scripts/translateNewKeys.ts", "clean:cache": "rimraf node_modules/.cache", "clean": "rimraf dist", "release:patch": "release-it patch", diff --git a/scripts/translateNewKeys.ts b/scripts/translateNewKeys.ts new file mode 100644 index 00000000000..fd0c8f137c7 --- /dev/null +++ b/scripts/translateNewKeys.ts @@ -0,0 +1,128 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable no-continue */ +/* eslint-disable no-restricted-syntax */ +import * as fs from 'fs'; +import * as path from 'path'; + +const TRANSLATIONS_DIR = './src/translations'; +const SOURCE_LANG_FILE = 'en.json'; +const OPENAI_ENDPOINT = 'https://api.openai.com/v1/chat/completions'; // Adjust if needed +const OPENAI_API_KEY = 'sk-YOURKEYHERE'; // Remember to replace with your actual key + + +async function main() { + const sourceLangContent = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, SOURCE_LANG_FILE), 'utf8')); + + const files = fs.readdirSync(TRANSLATIONS_DIR); + + for (const file of files) { + if (file === SOURCE_LANG_FILE) { + continue; + } + // check if file ends with .json + if (!file.endsWith('.json')) { + continue; + } + + // skip the translation-schema.json file + if (file === 'translation-schema.json') { + continue; + } + console.log('Processing file:', file); + + const targetLangContent = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, file), 'utf8')); + const missingKeys = findMissingKeys(sourceLangContent, targetLangContent); + + let hasChanged = false; + + for (const missingKey of missingKeys) { + const keys = missingKey.split('.'); + const sourceText = keys.reduce((acc, key) => acc[key], sourceLangContent); + const targetLang = file.split('.')[0]; + + const translatedText = await translateText(sourceText, targetLang); + let targetObj = targetLangContent; + + for (let i = 0; i < keys.length - 1; i += 1) { + if (!targetObj[keys[i]]) { + targetObj[keys[i]] = {}; + } + targetObj = targetObj[keys[i]]; + } + + targetObj[keys[keys.length - 1]] = translatedText; + hasChanged = true; + } + + + if (hasChanged) { + const sortedContent = sortKeys(targetLangContent); + fs.writeFileSync(path.join(TRANSLATIONS_DIR, file), JSON.stringify(sortedContent, null, 2)); + } + } +} + +main().then(() => { + console.log('Translation update completed.'); +}).catch((error) => { + console.error('Error occurred:', error); +}); + +async function translateText(text: string, targetLang: string): Promise { + const response = await fetch(OPENAI_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + max_tokens: 150, + model: 'gpt-4', + messages: [ + { + role: 'system', + content: `Only respond with the translation of the text you receive. The original language is English and the translation language is ${targetLang}. Only respond with the translation - do not say anything else. If you cannot translate the text, respond with "[SKIPPED]"`, + }, + { + role: 'user', + content: text, + }, + ], + }), + }); + + const data = await response.json(); + console.log(' Old text:', text, 'New text:', data.choices[0].message.content.trim()); + return data.choices[0].message.content.trim(); +} + +function findMissingKeys(baseObj: any, targetObj: any, prefix = ''): string[] { + let missingKeys = []; + + for (const key in baseObj) { + if (typeof baseObj[key] === 'object') { + missingKeys = missingKeys.concat(findMissingKeys(baseObj[key], targetObj[key] || {}, `${prefix}${key}.`)); + } else if (!(key in targetObj)) { + missingKeys.push(`${prefix}${key}`); + } + } + + return missingKeys; +} + +function sortKeys(obj: any): any { + if (typeof obj !== 'object' || obj === null) return obj; + + if (Array.isArray(obj)) { + return obj.map(sortKeys); + } + + const sortedKeys = Object.keys(obj).sort(); + const sortedObj: { [key: string]: any } = {}; + + for (const key of sortedKeys) { + sortedObj[key] = sortKeys(obj[key]); + } + + return sortedObj; +} diff --git a/src/admin/components/elements/Button/index.tsx b/src/admin/components/elements/Button/index.tsx index 3dbc9b3edc9..23f62a89fcc 100644 --- a/src/admin/components/elements/Button/index.tsx +++ b/src/admin/components/elements/Button/index.tsx @@ -72,6 +72,7 @@ const Button = forwardRef((props, iconPosition = 'right', newTab, tooltip, + 'aria-label': ariaLabel, } = props; const [showTooltip, setShowTooltip] = React.useState(false); @@ -101,6 +102,8 @@ const Button = forwardRef((props, type, className: classes, disabled, + 'aria-disabled': disabled, + 'aria-label': ariaLabel, onMouseEnter: tooltip ? () => setShowTooltip(true) : undefined, onMouseLeave: tooltip ? () => setShowTooltip(false) : undefined, onClick: !disabled ? handleClick : undefined, diff --git a/src/admin/components/elements/Button/types.ts b/src/admin/components/elements/Button/types.ts index 48eaaa3afb7..600b9f9c5fd 100644 --- a/src/admin/components/elements/Button/types.ts +++ b/src/admin/components/elements/Button/types.ts @@ -19,4 +19,5 @@ export type Props = { iconPosition?: 'left' | 'right', newTab?: boolean tooltip?: string + 'aria-label'?: string } diff --git a/src/admin/components/elements/Card/index.scss b/src/admin/components/elements/Card/index.scss index 37d1bbf8f3d..1380c5bc594 100644 --- a/src/admin/components/elements/Card/index.scss +++ b/src/admin/components/elements/Card/index.scss @@ -5,7 +5,8 @@ padding: base(1.25) $baseline; position: relative; - h5 { + &__title { + @extend %h5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/admin/components/elements/Card/index.tsx b/src/admin/components/elements/Card/index.tsx index 697ecbaeddd..65050df016b 100644 --- a/src/admin/components/elements/Card/index.tsx +++ b/src/admin/components/elements/Card/index.tsx @@ -7,7 +7,7 @@ import './index.scss'; const baseClass = 'card'; const Card: React.FC = (props) => { - const { id, title, actions, onClick } = props; + const { id, title, titleAs, buttonAriaLabel, actions, onClick } = props; const classes = [ baseClass, @@ -15,14 +15,16 @@ const Card: React.FC = (props) => { onClick && `${baseClass}--has-onclick`, ].filter(Boolean).join(' '); + const Tag = titleAs ?? 'div'; + return (
-
+ {title} -
+ {actions && (
{actions} @@ -30,6 +32,7 @@ const Card: React.FC = (props) => { )} {onClick && ( @@ -58,6 +59,7 @@ const SortColumn: React.FC = (props) => { buttonStyle="none" className={descClasses.join(' ')} onClick={() => setSort(desc)} + aria-label={t('sortByLabelDirection', { label: getTranslation(label, i18n), direction: t('descending') })} > diff --git a/src/admin/components/forms/field-types/Checkbox/Input.tsx b/src/admin/components/forms/field-types/Checkbox/Input.tsx index 8532a8c6098..45afc33b630 100644 --- a/src/admin/components/forms/field-types/Checkbox/Input.tsx +++ b/src/admin/components/forms/field-types/Checkbox/Input.tsx @@ -3,60 +3,72 @@ import Check from '../../../icons/Check'; import Label from '../../Label'; import './index.scss'; +import Line from '../../../icons/Line'; const baseClass = 'custom-checkbox'; type CheckboxInputProps = { - onToggle: React.MouseEventHandler + onToggle: React.FormEventHandler inputRef?: React.MutableRefObject readOnly?: boolean checked?: boolean + partialChecked?: boolean name?: string id?: string label?: string + 'aria-label'?: string required?: boolean } + export const CheckboxInput: React.FC = (props) => { const { onToggle, checked, + partialChecked, inputRef, name, id, label, + 'aria-label': ariaLabel, readOnly, required, } = props; return ( - - - - + )} +
); }; diff --git a/src/admin/components/forms/field-types/Checkbox/index.scss b/src/admin/components/forms/field-types/Checkbox/index.scss index aabd160a24f..3ec3719c8ba 100644 --- a/src/admin/components/forms/field-types/Checkbox/index.scss +++ b/src/admin/components/forms/field-types/Checkbox/index.scss @@ -4,10 +4,6 @@ position: relative; margin-bottom: $baseline; - input[type=checkbox] { - display: none; - } - .tooltip:not([aria-hidden="true"]) { right: auto; position: relative; @@ -22,74 +18,69 @@ .custom-checkbox { + display: inline-flex; + label { padding-bottom: 0; + padding-left: base(.5); } + + &:hover { + cursor: pointer; - input { - // hidden HTML checkbox - position: absolute; - top: 0; - left: 0; - opacity: 0; + svg { + opacity: 0.2; + } } &__input { - // visible checkbox @include formInput; + display: flex; padding: 0; line-height: 0; position: relative; width: $baseline; height: $baseline; - margin-right: base(.5); - svg { + & input[type="checkbox"] { + width: 100%; + height: 100%; + padding: 0; + margin: 0; opacity: 0; + border-radius: 0; + z-index: 1; + cursor: pointer; } - } - &--read-only { - .custom-checkbox__input { - background-color: var(--theme-elevation-100); - } - - label { - color: var(--theme-elevation-400); + &:focus-within { + box-shadow: 0 0 3px 3px var(--theme-success-400); } } - button { - @extend %btn-reset; - display: flex; - align-items: center; - cursor: pointer; - - &:focus, - &:active { - outline: none; - } + &__icon { + position: absolute; - &:focus { - .custom-checkbox__input { - box-shadow: 0 0 3px 3px var(--theme-success-400); - } + svg { + opacity: 0; } + } - &:hover { + &--checked { + .custom-checkbox__icon { svg { - opacity: .2; + opacity: 1; } } } - &--checked { - button { - .custom-checkbox__input { - svg { - opacity: 1; - } - } + &--read-only { + .custom-checkbox__input { + background-color: var(--theme-elevation-100); + } + + label { + color: var(--theme-elevation-400); } } } diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index 1ea8c0df673..662da8a3ac7 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -130,9 +130,11 @@ const DefaultAccount: React.FC = (props) => {

{t('general:payloadSettings')}