From d10e75498f44f7deb2a4dbc62f3f17304bc0d370 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Tue, 7 Nov 2023 12:51:44 -0500 Subject: [PATCH] feat: allow events to be registered without collection specified (#972) --- packages/core/src/interface.ts | 38 +- .../core/src/lib/__tests__/registry.spec.ts | 1399 +++++++++++++++++ .../core/src/lib/hooks/useEntryCallback.ts | 4 +- packages/core/src/lib/registry.ts | 468 +++--- packages/docs/content/docs/cms-events.mdx | 203 ++- 5 files changed, 1900 insertions(+), 212 deletions(-) create mode 100644 packages/core/src/lib/__tests__/registry.spec.ts diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index a8281aded..e7746a21f 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -1121,33 +1121,46 @@ export interface EventData { export interface PrePublishEventListener { name: 'prePublish'; - collection: string; + collection?: string; file?: string; - handler: (event: { data: EventData; collection: string }) => void | Promise; + handler: (event: { + data: EventData; + collection: string; + file: string | undefined; + }) => void | Promise; } export interface PostPublishEventListener { name: 'postPublish'; - collection: string; + collection?: string; file?: string; - handler: (event: { data: EventData; collection: string }) => void | Promise; + handler: (event: { + data: EventData; + collection: string; + file: string | undefined; + }) => void | Promise; } export interface PreSaveEventListener { name: 'preSave'; - collection: string; + collection?: string; file?: string; handler: (event: { data: EventData; collection: string; + file: string | undefined; }) => EntryData | undefined | null | void | Promise; } export interface PostSaveEventListener { name: 'postSave'; - collection: string; + collection?: string; file?: string; - handler: (event: { data: EventData; collection: string }) => void | Promise; + handler: (event: { + data: EventData; + collection: string; + file: string | undefined; + }) => void | Promise; } export interface MountedEventListener { @@ -1157,9 +1170,7 @@ export interface MountedEventListener { export interface LoginEventListener { name: 'login'; - handler: (event: { - author: AuthorData; - }) => EntryData | undefined | null | void | Promise; + handler: (event: { author: AuthorData }) => void | Promise; } export interface LogoutEventListener { @@ -1169,17 +1180,20 @@ export interface LogoutEventListener { export interface ChangeEventListener { name: 'change'; - collection: string; + collection?: string; file?: string; - field: string; + field?: string; handler: (event: { data: EntryData; collection: string; + file: string | undefined; field: string; }) => EntryData | undefined | null | void | Promise; } export type EventListener = + | PrePublishEventListener + | PostPublishEventListener | PreSaveEventListener | PostSaveEventListener | ChangeEventListener diff --git a/packages/core/src/lib/__tests__/registry.spec.ts b/packages/core/src/lib/__tests__/registry.spec.ts new file mode 100644 index 000000000..bc2c7ea16 --- /dev/null +++ b/packages/core/src/lib/__tests__/registry.spec.ts @@ -0,0 +1,1399 @@ +import { createMockEntry } from '@staticcms/test/data/entry.mock'; +import { invokeEvent, registerEventListener, removeEventListener } from '../registry'; +import set from '../util/set.util'; + +import type { EventListener } from '@staticcms/core/interface'; + +describe('registry', () => { + let listener: EventListener | undefined; + + afterEach(() => { + if (listener) { + removeEventListener(listener); + listener = undefined; + } + }); + + describe('events', () => { + it('should invoke registered login event', done => { + listener = { + name: 'login', + handler: ({ author }) => { + expect(author).toEqual({ + login: 'username', + name: 'User Name', + }); + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'login', + data: { + login: 'username', + name: 'User Name', + }, + }); + }); + + it('should invoke registered logout event', done => { + listener = { + name: 'logout', + handler: () => { + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'logout', + }); + }); + + it('should invoke registered mounted event', done => { + listener = { + name: 'mounted', + handler: () => { + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'mounted', + }); + }); + + describe('change event', () => { + describe('folder collection', () => { + it('should invoke event listener for all changes', async () => { + listener = { + name: 'change', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: undefined, + field: 'path.to.field', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + return set(data.data, data.field, 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'change', + collection: 'collectionName', + field: 'path.to.field', + fieldName: 'fieldName', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for whole collection changes', async () => { + listener = { + name: 'change', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: undefined, + field: 'path.to.field', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + return set(data.data, data.field, 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'change', + collection: 'collectionName', + field: 'path.to.field', + fieldName: 'fieldName', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for specific field changes', async () => { + listener = { + name: 'change', + collection: 'collectionName', + field: 'path.to.field', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: undefined, + field: 'path.to.field', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + return set(data.data, data.field, 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'change', + collection: 'collectionName', + field: 'path.to.field', + fieldName: 'fieldName', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + }); + + describe('file collection', () => { + it('should invoke event listener for all changes', async () => { + listener = { + name: 'change', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + return set(data.data, data.field, 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'change', + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + fieldName: 'fieldName', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for whole collection changes', async () => { + listener = { + name: 'change', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + return set(data.data, data.field, 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'change', + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + fieldName: 'fieldName', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for whole file changes', async () => { + listener = { + name: 'change', + collection: 'collectionName', + file: 'fileName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + return set(data.data, data.field, 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'change', + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + fieldName: 'fieldName', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for specific field changes', async () => { + listener = { + name: 'change', + collection: 'collectionName', + field: 'path.to.field', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + return set(data.data, data.field, 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'change', + collection: 'collectionName', + file: 'fileName', + field: 'path.to.field', + fieldName: 'fieldName', + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + }); + }); + + describe('preSave event', () => { + describe('folder collection', () => { + it('should invoke event listener for all changes', async () => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'preSave', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: undefined, + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + return set(data.data.entry.data, 'path.to.field', 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'preSave', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for whole collection changes', async () => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'preSave', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + return set(data.data.entry.data, 'path.to.field', 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'preSave', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + }); + + describe('file collection', () => { + it('should invoke event listener for all changes', async () => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'preSave', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + return set(data.data.entry.data, 'path.to.field', 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'preSave', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for whole collection changes', async () => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'preSave', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + return set(data.data.entry.data, 'path.to.field', 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'preSave', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + + it('should invoke event listener for whole file changes', async () => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'preSave', + collection: 'collectionName', + file: 'fileName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + return set(data.data.entry.data, 'path.to.field', 'NEW FIELD VALUE'); + }, + }; + + registerEventListener(listener); + + expect( + await invokeEvent({ + name: 'preSave', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }), + ).toEqual({ + path: { + to: { + field: 'NEW FIELD VALUE', + }, + }, + }); + }); + }); + }); + + describe('postSave event', () => { + describe('folder collection', () => { + it('should invoke event listener for all changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postSave', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: undefined, + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postSave', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole collection changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postSave', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postSave', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + }); + + describe('file collection', () => { + it('should invoke event listener for all changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postSave', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postSave', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole collection changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postSave', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postSave', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole file changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postSave', + collection: 'collectionName', + file: 'fileName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postSave', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + }); + }); + + describe('prePublish event', () => { + describe('folder collection', () => { + it('should invoke event listener for all changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'prePublish', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: undefined, + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'prePublish', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole collection changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'prePublish', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'prePublish', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + }); + + describe('file collection', () => { + it('should invoke event listener for all changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'prePublish', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'prePublish', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole collection changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'prePublish', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'prePublish', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole file changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'prePublish', + collection: 'collectionName', + file: 'fileName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'prePublish', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + }); + }); + + describe('postPublish event', () => { + describe('folder collection', () => { + it('should invoke event listener for all changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postPublish', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: undefined, + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postPublish', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole collection changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postPublish', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postPublish', + collection: 'collectionName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + }); + + describe('file collection', () => { + it('should invoke event listener for all changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postPublish', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postPublish', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole collection changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postPublish', + collection: 'collectionName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postPublish', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + + it('should invoke event listener for whole file changes', done => { + const entry = createMockEntry({ + data: { + path: { + to: { + field: 'fieldValue', + }, + }, + }, + }); + + listener = { + name: 'postPublish', + collection: 'collectionName', + file: 'fileName', + handler: data => { + expect(data).toEqual({ + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + + done(); + }, + }; + + registerEventListener(listener); + + invokeEvent({ + name: 'postPublish', + collection: 'collectionName', + file: 'fileName', + data: { + author: { + login: 'username', + name: 'User Name', + }, + entry, + }, + }); + }); + }); + }); + }); +}); diff --git a/packages/core/src/lib/hooks/useEntryCallback.ts b/packages/core/src/lib/hooks/useEntryCallback.ts index 7d192265e..e86136c56 100644 --- a/packages/core/src/lib/hooks/useEntryCallback.ts +++ b/packages/core/src/lib/hooks/useEntryCallback.ts @@ -32,8 +32,8 @@ async function handleChange( newEntry = await invokeEvent({ name: 'change', collection, - field: field.name, - fieldPath, + fieldName: field.name, + field: fieldPath, data: newEntry, }); diff --git a/packages/core/src/lib/registry.ts b/packages/core/src/lib/registry.ts index 0f194c62c..f3d8dd415 100644 --- a/packages/core/src/lib/registry.ts +++ b/packages/core/src/lib/registry.ts @@ -53,33 +53,26 @@ export const allowedEvents = [ ] as const; export type AllowedEvent = (typeof allowedEvents)[number]; -type EventHandlerRegistry = { - prePublish: Record< - string, - PrePublishEventListener['handler'][] | Record - >; - postPublish: Record< - string, - PostPublishEventListener['handler'][] | Record - >; - preSave: Record< - string, - PreSaveEventListener['handler'][] | Record - >; - postSave: Record< - string, - PostSaveEventListener['handler'][] | Record - >; +export interface CollectionListener { + all: T[]; + collections: Record; + files: Record>; +} + +export interface ChangeListener extends CollectionListener { + collectionField: Record>; + fileField: Record>>; +} + +export type EventHandlerRegistry = { + prePublish: CollectionListener; + postPublish: CollectionListener; + preSave: CollectionListener; + postSave: CollectionListener; mounted: MountedEventListener['handler'][]; login: LoginEventListener['handler'][]; logout: LogoutEventListener['handler'][]; - change: Record< - string, - Record< - string, - ChangeEventListener['handler'][] | Record - > - >; + change: ChangeListener; }; const eventHandlers = allowedEvents.reduce((acc, e) => { @@ -88,8 +81,20 @@ const eventHandlers = allowedEvents.reduce((acc, e) => { case 'postPublish': case 'preSave': case 'postSave': + acc[e] = { + all: [], + collections: {}, + files: {}, + }; + break; case 'change': - acc[e] = {}; + acc[e] = { + all: [], + collections: {}, + files: {}, + collectionField: {}, + fileField: {}, + }; break; default: acc[e] = []; @@ -405,21 +410,47 @@ function validateEventName(name: AllowedEvent) { } } -export function getEventListeners(options: { +export function getEventListeners(options: { name: AllowedEvent; collection?: string; + file?: string; field?: string; -}) { - const { name } = options; +}): T[] { + const { name, collection, file, field } = options; validateEventName(name); + const handlers: T[] = []; + if (name === 'change') { - if (!options.field || !options.collection) { - return []; + handlers.push(...(registry.eventHandlers[name].all as T[])); + if (!collection) { + return handlers; + } + + handlers.push(...((registry.eventHandlers[name].collections[collection] ?? []) as T[])); + if (!file && !field) { + return handlers; + } + + if (field) { + handlers.push( + ...((registry.eventHandlers[name].collectionField[collection]?.[field] ?? []) as T[]), + ); + } + if (!file) { + return handlers; } - return (registry.eventHandlers[name][options.collection] ?? {})[options.field] ?? []; + handlers.push(...((registry.eventHandlers[name].files[collection]?.[file] ?? []) as T[])); + if (!field) { + return handlers; + } + + handlers.push( + ...((registry.eventHandlers[name].fileField[collection]?.[file]?.[field] ?? []) as T[]), + ); + return handlers; } if ( @@ -428,14 +459,21 @@ export function getEventListeners(options: { name === 'preSave' || name === 'postSave' ) { - if (!options.collection) { - return []; + handlers.push(...(registry.eventHandlers[name].all as T[])); + if (!collection) { + return handlers; } - return registry.eventHandlers[name][options.collection] ?? []; + handlers.push(...((registry.eventHandlers[name].collections[collection] ?? []) as T[])); + if (!file) { + return handlers; + } + + handlers.push(...((registry.eventHandlers[name].files[collection]?.[file] ?? []) as T[])); + return handlers; } - return [...registry.eventHandlers[name]]; + return [...registry.eventHandlers[name]] as T[]; } export function registerEventListener(listener: EventListener) { @@ -447,81 +485,113 @@ export function registerEventListener(listener: EventListener) { const file = listener.file; const field = listener.field; + if (!collection) { + registry.eventHandlers[name].all.push(handler); + return; + } + if (!(collection in registry.eventHandlers[name])) { - registry.eventHandlers[name][collection] = {}; + registry.eventHandlers[name].collections[collection] = []; + registry.eventHandlers[name].collectionField[collection] = {}; + registry.eventHandlers[name].files[collection] = {}; + registry.eventHandlers[name].fileField[collection] = {}; } - if (file) { - if (!(file in registry.eventHandlers[name][collection])) { - registry.eventHandlers[name][collection][file] = {}; + if (!file) { + if (!field) { + registry.eventHandlers[name].collections[collection].push(handler); + return; } - if (Array.isArray(registry.eventHandlers[name][collection][file])) { - return; + if (!(field in registry.eventHandlers[name].collectionField[collection])) { + registry.eventHandlers[name].collectionField[collection][field] = []; } - if (!(field in registry.eventHandlers[name][collection][file])) { - ( - registry.eventHandlers[name][collection][file] as Record< - string, - ChangeEventListener['handler'][] - > - )[field] = []; + registry.eventHandlers[name].collectionField[collection][field].push(handler); + return; + } + + if (!field) { + if (!(file in registry.eventHandlers[name].files[collection])) { + registry.eventHandlers[name].files[collection][file] = []; } - ( - registry.eventHandlers[name][collection][file] as Record< - string, - ChangeEventListener['handler'][] - > - )[field].push(handler); + registry.eventHandlers[name].files[collection][file].push(handler); return; } - if (!(field in registry.eventHandlers[name][collection])) { - registry.eventHandlers[name][collection][field] = []; + if (!(file in registry.eventHandlers[name].fileField[collection])) { + registry.eventHandlers[name].fileField[collection][file] = {}; } - if (!Array.isArray(registry.eventHandlers[name][collection][field])) { - return; + if (!(field in registry.eventHandlers[name].fileField[collection][file])) { + registry.eventHandlers[name].fileField[collection][file][field] = []; } - (registry.eventHandlers[name][collection][field] as ChangeEventListener['handler'][]).push( - handler, - ); + registry.eventHandlers[name].fileField[collection][file][field].push(handler); return; } - if (name === 'preSave' || name === 'postSave') { + if (name === 'preSave') { const collection = listener.collection; const file = listener.file; - if (file) { - if (!(collection in registry.eventHandlers[name])) { - registry.eventHandlers[name][collection] = {}; - } + if (!collection) { + registry.eventHandlers[name].all.push(handler); + return; + } - if (!(file in registry.eventHandlers[name][collection])) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (registry.eventHandlers[name][collection] as Record)[file] = []; - } + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name].collections[collection] = []; + registry.eventHandlers[name].files[collection] = {}; + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (registry.eventHandlers[name][collection] as Record)[file].push(handler); + if (!file) { + registry.eventHandlers[name].collections[collection].push(handler); + return; + } + + if (!(file in registry.eventHandlers[name].files[collection])) { + registry.eventHandlers[name].files[collection][file] = []; + } + + registry.eventHandlers[name].files[collection][file].push(handler); + return; + } + + if (name === 'postSave' || name === 'prePublish' || name === 'postPublish') { + const collection = listener.collection; + const file = listener.file; + + if (!collection) { + registry.eventHandlers[name].all.push(handler); return; } if (!(collection in registry.eventHandlers[name])) { - registry.eventHandlers[name][collection] = []; + registry.eventHandlers[name].collections[collection] = []; + registry.eventHandlers[name].files[collection] = {}; + } + + if (!file) { + registry.eventHandlers[name].collections[collection].push(handler); + return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (registry.eventHandlers[name][collection] as any[]).push(handler); + if (!(file in registry.eventHandlers[name].files[collection])) { + registry.eventHandlers[name].files[collection][file] = []; + } + + registry.eventHandlers[name].files[collection][file].push(handler); return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - registry.eventHandlers[name].push(handler as any); + if (name === 'login') { + registry.eventHandlers[name].push(handler); + return; + } + + registry.eventHandlers[name].push(handler); } export async function invokeEvent(event: { name: 'login'; data: AuthorData }): Promise; @@ -556,17 +626,18 @@ export async function invokeEvent(event: { data: EntryData | undefined; collection: string; file?: string; + fieldName: string; field: string; - fieldPath: string; }): Promise; export async function invokeEvent(event: { name: AllowedEvent; data?: EventData | EntryData | AuthorData; collection?: string; file?: string; + fieldName?: string; field?: string; }): Promise { - const { name, data, collection, field } = event; + const { name, data, collection, file, fieldName, field } = event; validateEventName(name); @@ -590,36 +661,26 @@ export async function invokeEvent(event: { return; } - if (name === 'postSave') { + if (name === 'postSave' || name === 'prePublish' || name === 'postPublish') { if (!collection) { return; } + const handlers = getEventListeners< + | PrePublishEventListener['handler'] + | PostPublishEventListener['handler'] + | PostSaveEventListener['handler'] + >({ name, collection, file }); + console.info( `[StaticCMS] Firing post save event for${ - event.file ? ` "${event.file}" file in` : '' + file ? ` "${file}" file in` : '' } "${collection}" collection`, data, ); - const handlers = registry.eventHandlers[name][collection]; - - let finalHandlers: PostSaveEventListener['handler'][]; - if (event.file && !Array.isArray(handlers)) { - finalHandlers = - ( - registry.eventHandlers[name][collection] as Record< - string, - PostSaveEventListener['handler'][] - > - )[event.file] ?? []; - } else if (Array.isArray(handlers)) { - finalHandlers = handlers ?? []; - } else { - finalHandlers = []; - } - for (const handler of finalHandlers) { - handler({ data: data as EventData, collection }); + for (const handler of handlers) { + handler({ data: data as EventData, collection, file }); } return; @@ -630,34 +691,22 @@ export async function invokeEvent(event: { return; } + const handlers = getEventListeners({ + name, + collection, + file, + field, + }); + let _data = cloneDeep(data as EntryData); console.info( - `[StaticCMS] Firing change event for field "${field}" for${ + `[StaticCMS] Firing change event for field "${fieldName ?? field}" for${ event.file ? ` "${event.file}" file in` : '' } "${collection}" collection`, ); - const collectionHandlers = registry.eventHandlers[name][collection] ?? {}; - - let finalHandlers: Record; - if ( - event.file && - event.file in collectionHandlers && - !Array.isArray(collectionHandlers[event.file]) - ) { - finalHandlers = - (collectionHandlers as Record>)[ - event.file - ] ?? {}; - } else if (Array.isArray(collectionHandlers[field])) { - finalHandlers = collectionHandlers as Record; - } else { - finalHandlers = {}; - } - - const handlers = finalHandlers[field] ?? []; for (const handler of handlers) { - const result = await handler({ data: _data, collection, field }); + const result = await handler({ data: _data, collection, file, field }); if (_data !== undefined && result) { _data = result; } @@ -677,25 +726,11 @@ export async function invokeEvent(event: { } "${collection}" collection`, data, ); - const handlers = registry.eventHandlers[name][collection] ?? []; - - let finalHandlers: PreSaveEventListener['handler'][]; - if (event.file && !Array.isArray(handlers)) { - finalHandlers = - ( - registry.eventHandlers[name][collection] as Record< - string, - PreSaveEventListener['handler'][] - > - )[event.file] ?? []; - } else if (Array.isArray(handlers)) { - finalHandlers = handlers ?? []; - } else { - finalHandlers = []; - } - for (const handler of finalHandlers) { - const result = await handler({ data: _data, collection }); + const handlers = getEventListeners({ name, collection, file }); + + for (const handler of handlers) { + const result = await handler({ data: _data, collection, file }); if (_data !== undefined && result !== undefined) { const entry = { ..._data.entry, @@ -708,91 +743,162 @@ export async function invokeEvent(event: { return _data.entry.data; } +function filterEventListeners( + handlers: T[], + handlerToRemove: T, +): T[] { + return handlers.filter(item => item !== handlerToRemove); +} + export function removeEventListener(listener: EventListener) { const { name, handler } = listener; - validateEventName(name); + if (name === 'change') { const collection = listener.collection; const file = listener.file; const field = listener.field; + if (!collection) { + registry.eventHandlers[name].all = filterEventListeners( + registry.eventHandlers[name].all, + handler, + ); + return; + } + if (!(collection in registry.eventHandlers[name])) { - registry.eventHandlers[name][collection] = {}; + registry.eventHandlers[name].collections[collection] = []; + registry.eventHandlers[name].collectionField[collection] = {}; + registry.eventHandlers[name].files[collection] = {}; + registry.eventHandlers[name].fileField[collection] = {}; } - if (file) { - if (!(file in registry.eventHandlers[name][collection])) { - registry.eventHandlers[name][collection][file] = {}; + if (!file) { + if (!field) { + registry.eventHandlers[name].collections[collection] = filterEventListeners( + registry.eventHandlers[name].collections[collection], + handler, + ); + return; } - if (!(field in registry.eventHandlers[name][collection][file])) { - ( - registry.eventHandlers[name][collection][file] as Record< - string, - ChangeEventListener['handler'][] - > - )[field] = []; + if (!(field in registry.eventHandlers[name].collectionField[collection])) { + registry.eventHandlers[name].collectionField[collection][field] = []; } - ( - registry.eventHandlers[name][collection][file] as Record< - string, - ChangeEventListener['handler'][] - > - )[field].filter(item => item !== handler); + registry.eventHandlers[name].collectionField[collection][field] = filterEventListeners( + registry.eventHandlers[name].collectionField[collection][field], + handler, + ); + return; + } + + if (!field) { + if (!(file in registry.eventHandlers[name].files[collection])) { + registry.eventHandlers[name].files[collection][file] = []; + } + registry.eventHandlers[name].files[collection][file] = filterEventListeners( + registry.eventHandlers[name].files[collection][file], + handler, + ); return; } - if (!(field in registry.eventHandlers[name][collection])) { - registry.eventHandlers[name][collection][field] = []; + if (!(file in registry.eventHandlers[name].fileField[collection])) { + registry.eventHandlers[name].fileField[collection][file] = {}; } - if (!Array.isArray(registry.eventHandlers[name][collection][field])) { - return; + if (!(field in registry.eventHandlers[name].fileField[collection][file])) { + registry.eventHandlers[name].fileField[collection][file][field] = []; } - (registry.eventHandlers[name][collection][field] as ChangeEventListener['handler'][]).filter( - item => item !== handler, + registry.eventHandlers[name].fileField[collection][file][field] = filterEventListeners( + registry.eventHandlers[name].fileField[collection][file][field], + handler, ); return; } - if (name === 'preSave' || name === 'postSave') { + if (name === 'preSave') { const collection = listener.collection; const file = listener.file; - if (file) { - if (!(collection in registry.eventHandlers[name])) { - registry.eventHandlers[name][collection] = {}; - } + if (!collection) { + registry.eventHandlers[name].all = filterEventListeners( + registry.eventHandlers[name].all, + handler, + ); + return; + } - if (!(file in registry.eventHandlers[name][collection])) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (registry.eventHandlers[name][collection] as Record)[file] = []; - } + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name].collections[collection] = []; + registry.eventHandlers[name].files[collection] = {}; + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (registry.eventHandlers[name][collection] as Record)[file].filter( - item => item !== handler, + if (!file) { + registry.eventHandlers[name].collections[collection] = filterEventListeners( + registry.eventHandlers[name].collections[collection], + handler, + ); + return; + } + + if (!(file in registry.eventHandlers[name].files[collection])) { + registry.eventHandlers[name].files[collection][file] = []; + } + + registry.eventHandlers[name].files[collection][file] = filterEventListeners( + registry.eventHandlers[name].files[collection][file], + handler, + ); + return; + } + + if (name === 'postSave' || name === 'prePublish' || name === 'postPublish') { + const collection = listener.collection; + const file = listener.file; + + if (!collection) { + registry.eventHandlers[name].all = filterEventListeners( + registry.eventHandlers[name].all, + handler, ); return; } if (!(collection in registry.eventHandlers[name])) { - registry.eventHandlers[name][collection] = []; + registry.eventHandlers[name].collections[collection] = []; + registry.eventHandlers[name].files[collection] = {}; + } + + if (!file) { + registry.eventHandlers[name].collections[collection] = filterEventListeners( + registry.eventHandlers[name].collections[collection], + handler, + ); + return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (registry.eventHandlers[name][collection] as any[]).filter(item => item !== handler); + if (!(file in registry.eventHandlers[name].files[collection])) { + registry.eventHandlers[name].files[collection][file] = []; + } + + registry.eventHandlers[name].files[collection][file] = filterEventListeners( + registry.eventHandlers[name].files[collection][file], + handler, + ); + return; + } + + if (name === 'login') { + registry.eventHandlers[name] = filterEventListeners(registry.eventHandlers[name], handler); return; } - registry.eventHandlers[name] = registry.eventHandlers[name].filter( - item => item !== handler, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) as any; + registry.eventHandlers[name] = filterEventListeners(registry.eventHandlers[name], handler); } /** diff --git a/packages/docs/content/docs/cms-events.mdx b/packages/docs/content/docs/cms-events.mdx index f5d699799..7c40610e5 100644 --- a/packages/docs/content/docs/cms-events.mdx +++ b/packages/docs/content/docs/cms-events.mdx @@ -8,16 +8,16 @@ You can execute a function when a specific event occurs within Static CMSD. Supported events are: -| Name | Description | -| ----------- | ----------------------------------------------------------------------------------------------------------------------- | -| mounted | Event fires once Static CMS is fully loaded | -| login | Event fires when a user logs into Static CMS | -| logout | Event fires when a user logs out of Static CMS | -| change | Event fires when a user changes the value of a field in the editor | -| preSave | Event fires before the changes have been saved to your git backend | -| postSave | Event fires after the changes have been saved to your git backend | -| prePublish | _**Editorial Workflow ONLY**_. Event fires before the entry is "published", before the PR is merged into default branch | -| postPublish | _**Editorial Workflow ONLY**_. Event fires after the entry is "published", after the PR is merged into default branch | +| Name | Description | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| [mounted](#mounted-event) | Event fires once Static CMS is fully loaded | +| [login](#login-event) | Event fires when a user logs into Static CMS | +| [logout](#logout-event) | Event fires when a user logs out of Static CMS | +| [change](#change-event) | Event fires when a user changes the value of a field in the editor | +| [preSave](#pre-save-event) | Event fires before the changes have been saved to your git backend | +| [postSave](#post-save-event) | Event fires after the changes have been saved to your git backend | +| [prePublish](#pre-publish-event) | _**Editorial Workflow ONLY**_. Event fires before the entry is "published", before the PR is merged into default branch | +| [postPublish](#post-publish-event) | _**Editorial Workflow ONLY**_. Event fires after the entry is "published", after the PR is merged into default branch | ## Mounted Event @@ -60,12 +60,52 @@ CMS.registerEventListener({ ## Change Event -The `change` event handler fires when a user changes the value of a field in the editor. Event listeners for `change` must provide a field name, and can be used to modify the entry data like so: +The `change` event handler fires when a user changes the value of a field in the editor. Event listeners for `change` can optionally provide collection, file and field names. They can also be used to modify the entry data. ```javascript +// Listen for ALL change events +CMS.registerEventListener({ + name: 'change', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all change events in a specific collection CMS.registerEventListener({ name: 'change', collection: 'posts', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all change events in a specific file in a collection +CMS.registerEventListener({ + name: 'change', + collection: 'settings', + file: 'global', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all change events in a specific field in a collection +CMS.registerEventListener({ + name: 'change', + collection: 'posts', + // file: 'global', // You can specify a file if in a file collection + field: 'path.to.my.field', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Alter the entry data when a specific field changes +CMS.registerEventListener({ + name: 'change', + collection: 'posts', + // file: 'global', // You can specify a file if in a file collection field: 'path.to.my.field', handler: ({ data, collection, field }) => { const currentValue = data.path.to.my.field; @@ -89,16 +129,56 @@ CMS.registerEventListener({ ## Pre Save Event -The `preSave` event handler fires before the changes have been saved to your git backend, and can be used to modify the entry data like so: +The `preSave` event handler fires before the changes have been saved to your git backend, and can be used to modify the entry data. ```javascript +// Listen for ALL preSave events +CMS.registerEventListener({ + name: 'preSave', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all preSave events in a specific collection CMS.registerEventListener({ name: 'preSave', collection: 'posts', - handler: ({ data: { entry } }) => { + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all preSave events in a specific file in a collection +CMS.registerEventListener({ + name: 'preSave', + collection: 'settings', + file: 'global', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Alter the entry data +CMS.registerEventListener({ + name: 'preSave', + collection: 'posts', + // file: 'global', // You can specify a file if in a file collection + handler: ({ data, collection, field }) => { + const currentValue = data.path.to.my.field; + return { - ...entry.data, - title: 'new title', + ...data, + path: { + ...data.path, + to: { + ...data.path.to, + my: { + ...data.path.to.my, + field: `new${currentValue}`, + }, + }, + }, }; }, }); @@ -109,11 +189,100 @@ CMS.registerEventListener({ The `postSave` event handler fires after the changes have been saved to your git backend. ```javascript +// Listen for ALL postSave events +CMS.registerEventListener({ + name: 'postSave', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all postSave events in a specific collection CMS.registerEventListener({ name: 'postSave', collection: 'posts', - handler: ({ data: { entry } }) => { - // your code here + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all postSave events in a specific file in a collection +CMS.registerEventListener({ + name: 'postSave', + collection: 'settings', + file: 'global', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); +``` + +## Pre Publish Event + +Editorial Workflow ONLY + +The `prePublish` event handler fires before the entry is "published", before the PR is merged into default branch. + +```javascript +// Listen for ALL prePublish events +CMS.registerEventListener({ + name: 'prePublish', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all prePublish events in a specific collection +CMS.registerEventListener({ + name: 'prePublish', + collection: 'posts', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all prePublish events in a specific file in a collection +CMS.registerEventListener({ + name: 'prePublish', + collection: 'settings', + file: 'global', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); +``` + +## Post Publish Event + +Editorial Workflow ONLY + +The `postPublish` event handler fires after the entry is "published", after the PR is merged into default branch. + +```javascript +// Listen for ALL postPublish events +CMS.registerEventListener({ + name: 'postPublish', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all postPublish events in a specific collection +CMS.registerEventListener({ + name: 'postPublish', + collection: 'posts', + handler: ({ data, collection, field }) => { + // Your handler code + }, +}); + +// Listen for all postPublish events in a specific file in a collection +CMS.registerEventListener({ + name: 'postPublish', + collection: 'settings', + file: 'global', + handler: ({ data, collection, field }) => { + // Your handler code }, }); ```