From eff9a62c5b58bdffdf7e3bbe327a4f9ad51fd9b5 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Mon, 10 Jul 2023 15:49:08 -0400 Subject: [PATCH] feat: change event (#842) --- BREAKING_CHANGES.md | 5 +- packages/core/src/actions/auth.ts | 3 + packages/core/src/actions/entries.ts | 9 + packages/core/src/backend.ts | 12 +- .../src/backends/gitea/implementation.tsx | 2 - packages/core/src/components/App.tsx | 11 +- .../src/components/entry-editor/Editor.tsx | 10 + .../editor-control-pane/EditorControl.tsx | 20 +- packages/core/src/constants.ts | 1 + packages/core/src/interface.ts | 90 ++-- .../src/lib/hooks/useDebouncedCallback.ts | 50 ++- .../core/src/lib/hooks/useEntryCallback.ts | 112 +++++ packages/core/src/lib/registry.ts | 419 +++++++++++++++--- packages/core/src/reducers/entryDraft.ts | 34 ++ .../src/widgets/list/DelimitedListControl.tsx | 2 +- .../src/widgets/markdown/MarkdownPreview.tsx | 3 +- .../core/src/widgets/string/StringControl.tsx | 2 +- .../core/src/widgets/text/TextControl.tsx | 2 +- packages/docs/content/docs/beta-features.mdx | 24 +- .../docs/content/docs/collection-types.mdx | 73 +++ packages/docs/content/docs/widgets.mdx | 191 +++++++- 21 files changed, 928 insertions(+), 147 deletions(-) create mode 100644 packages/core/src/lib/hooks/useEntryCallback.ts diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 9d8cf4aa..37767148 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -1,2 +1,5 @@ +Breaking changes to be documented for v3 + - gitea API config has changed. -- hidden removed as widget parameter \ No newline at end of file +- hidden removed as widget parameter +- events \ No newline at end of file diff --git a/packages/core/src/actions/auth.ts b/packages/core/src/actions/auth.ts index 6240b0f5..42a2ece1 100644 --- a/packages/core/src/actions/auth.ts +++ b/packages/core/src/actions/auth.ts @@ -1,5 +1,6 @@ import { currentBackend } from '../backend'; import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants'; +import { invokeEvent } from '../lib/registry'; import { addSnackbar } from '../store/slices/snackbars'; import type { AnyAction } from 'redux'; @@ -35,6 +36,8 @@ export function doneAuthenticating() { } export function logout() { + invokeEvent({ name: 'logout' }); + return { type: LOGOUT, } as const; diff --git a/packages/core/src/actions/entries.ts b/packages/core/src/actions/entries.ts index 203ba1d5..8bf2ce48 100644 --- a/packages/core/src/actions/entries.ts +++ b/packages/core/src/actions/entries.ts @@ -4,6 +4,7 @@ import { currentBackend } from '../backend'; import { ADD_DRAFT_ENTRY_MEDIA_FILE, CHANGE_VIEW_STYLE, + DRAFT_UPDATE, DRAFT_CHANGE_FIELD, DRAFT_CREATE_DUPLICATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, @@ -458,6 +459,13 @@ export function discardDraft() { return { type: DRAFT_DISCARD } as const; } +export function updateDraft({ data }: { data: EntryData }) { + return { + type: DRAFT_UPDATE, + payload: { data }, + } as const; +} + export function changeDraftField({ path, field, @@ -1125,6 +1133,7 @@ export type EntriesAction = ReturnType< | typeof createDraftFromEntry | typeof draftDuplicateEntry | typeof discardDraft + | typeof updateDraft | typeof changeDraftField | typeof changeDraftFieldValidation | typeof localBackupRetrieved diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 618cb7df..12a6078b 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -859,7 +859,7 @@ export class Backend { + async invokePreSaveEvent(entry: Entry, collection: Collection): Promise { const eventData = await this.getEventData(entry); - return await invokeEvent('preSave', eventData); + return await invokeEvent({ name: 'preSave', collection: collection.name, data: eventData }); } - async invokePostSaveEvent(entry: Entry): Promise { + async invokePostSaveEvent(entry: Entry, collection: Collection): Promise { const eventData = await this.getEventData(entry); - await invokeEvent('postSave', eventData); + await invokeEvent({ name: 'postSave', collection: collection.name, data: eventData }); } async persistMedia(config: Config, file: AssetProxy) { diff --git a/packages/core/src/backends/gitea/implementation.tsx b/packages/core/src/backends/gitea/implementation.tsx index 79a4f601..b74f5232 100644 --- a/packages/core/src/backends/gitea/implementation.tsx +++ b/packages/core/src/backends/gitea/implementation.tsx @@ -173,8 +173,6 @@ export default class Gitea implements BackendClass { throw new Error('Your Gitea user account does not have access to this repo.'); } - console.log(user); - // Authorized user return { name: user.full_name, diff --git a/packages/core/src/components/App.tsx b/packages/core/src/components/App.tsx index 271ae985..a969301d 100644 --- a/packages/core/src/components/App.tsx +++ b/packages/core/src/components/App.tsx @@ -188,9 +188,12 @@ const App = ({ const [prevUser, setPrevUser] = useState(user); useEffect(() => { if (!prevUser && user) { - invokeEvent('login', { - login: user.login, - name: user.name ?? '', + invokeEvent({ + name: 'login', + data: { + login: user.login, + name: user.name ?? '', + }, }); } setPrevUser(user); @@ -243,7 +246,7 @@ const App = ({ useEffect(() => { setTimeout(() => { - invokeEvent('mounted'); + invokeEvent({ name: 'mounted' }); }); }, []); diff --git a/packages/core/src/components/entry-editor/Editor.tsx b/packages/core/src/components/entry-editor/Editor.tsx index 98fa7dc9..5f92f525 100644 --- a/packages/core/src/components/entry-editor/Editor.tsx +++ b/packages/core/src/components/entry-editor/Editor.tsx @@ -20,6 +20,7 @@ import { } from '@staticcms/core/actions/entries'; import { loadMedia } from '@staticcms/core/actions/mediaLibrary'; import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll'; +import useEntryCallback from '@staticcms/core/lib/hooks/useEntryCallback'; import { selectFields } from '@staticcms/core/lib/util/collection.util'; import { useWindowEvent } from '@staticcms/core/lib/util/window.util'; import { selectConfig } from '@staticcms/core/reducers/selectors/config'; @@ -210,6 +211,15 @@ const Editor: FC> = ({ }; }, [collection, createBackup, entryDraft.entry, hasChanged]); + useEntryCallback({ + hasChanged, + collection, + slug, + callback: () => { + setVersion(version => version + 1); + }, + }); + const [prevCollection, setPrevCollection] = useState(null); const [prevSlug, setPrevSlug] = useState(null); useEffect(() => { diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx index 40f595b1..25ca159a 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControl.tsx @@ -13,6 +13,7 @@ import { removeInsertedMedia as removeInsertedMediaAction, } from '@staticcms/core/actions/mediaLibrary'; import { query as queryAction } from '@staticcms/core/actions/search'; +import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback'; import useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare'; import useUUID from '@staticcms/core/lib/hooks/useUUID'; import { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n'; @@ -134,15 +135,18 @@ const EditorControl = ({ ]); const handleChangeDraftField = useCallback( - (value: ValueOrNestedValue) => { + async (value: ValueOrNestedValue) => { setDirty( oldDirty => oldDirty || !isEmpty(widget.getValidValue(value, field as UnknownField)), ); + changeDraftField({ path, field, value, i18n, isMeta }); }, [changeDraftField, field, i18n, isMeta, path, widget], ); + const handleDebouncedChangeDraftField = useDebouncedCallback(handleChangeDraftField, 250); + const config = useMemo(() => configState.config, [configState.config]); const finalValue = useMemoCompare(value, isEqual); @@ -155,21 +159,23 @@ const EditorControl = ({ if ('default' in field && isNotNullish(!field.default)) { if (widget.getDefaultValue) { - handleChangeDraftField( + handleDebouncedChangeDraftField( widget.getDefaultValue(field.default, field as unknown as UnknownField), ); } else { - handleChangeDraftField(field.default); + handleDebouncedChangeDraftField(field.default); } setVersion(version => version + 1); return; } if (widget.getDefaultValue) { - handleChangeDraftField(widget.getDefaultValue(null, field as unknown as UnknownField)); + handleDebouncedChangeDraftField( + widget.getDefaultValue(null, field as unknown as UnknownField), + ); setVersion(version => version + 1); } - }, [field, finalValue, handleChangeDraftField, widget]); + }, [field, finalValue, handleDebouncedChangeDraftField, widget]); return useMemo(() => { if (!collection || !entry || !config || field.widget === 'hidden') { @@ -192,7 +198,7 @@ const EditorControl = ({ label: getFieldLabel(field, t), locale, mediaPaths, - onChange: handleChangeDraftField, + onChange: handleDebouncedChangeDraftField, openMediaLibrary, removeInsertedMedia, path, @@ -225,7 +231,7 @@ const EditorControl = ({ t, locale, mediaPaths, - handleChangeDraftField, + handleDebouncedChangeDraftField, openMediaLibrary, removeInsertedMedia, path, diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index ad9bf6e3..0cc520de 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -47,6 +47,7 @@ export const GROUP_ENTRIES_FAILURE = 'GROUP_ENTRIES_FAILURE'; export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; +export const DRAFT_UPDATE = 'DRAFT_UPDATE'; export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD'; export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS'; export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED'; diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index f6130d9b..70251a4e 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -47,7 +47,6 @@ import type { I18N_STRUCTURE_MULTIPLE_FOLDERS, I18N_STRUCTURE_SINGLE_FILE, } from './lib/i18n'; -import type { AllowedEvent } from './lib/registry'; import type Cursor from './lib/util/Cursor'; import type AssetProxy from './valueObjects/AssetProxy'; @@ -922,45 +921,60 @@ export interface EventData { author: AuthorData; } -export type PreSaveEventHandler = Record> = ( - data: EventData, - options: O, -) => EntryData | undefined | null | void | Promise; - -export type PostSaveEventHandler = Record> = ( - data: EventData, - options: O, -) => void | Promise; - -export type MountedEventHandler = Record> = ( - options: O, -) => void | Promise; - -export type LoginEventHandler = Record> = ( - data: AuthorData, - options: O, -) => void | Promise; - -export type LogoutEventHandler = Record> = ( - options: O, -) => void | Promise; - -export type EventHandlers = Record> = { - preSave: PreSaveEventHandler; - postSave: PostSaveEventHandler; - mounted: MountedEventHandler; - login: LoginEventHandler; - logout: LogoutEventHandler; -}; - -export interface EventListener< - E extends AllowedEvent = 'mounted', - O extends Record = Record, -> { - name: E; - handler: EventHandlers[E]; +export interface PreSaveEventListener { + name: 'preSave'; + collection: string; + file?: string; + handler: (event: { + data: EventData; + collection: string; + }) => EntryData | undefined | null | void | Promise; } +export interface PostSaveEventListener { + name: 'postSave'; + collection: string; + file?: string; + handler: (event: { data: EventData; collection: string }) => void | Promise; +} + +export interface MountedEventListener { + name: 'mounted'; + handler: () => void | Promise; +} + +export interface LoginEventListener { + name: 'login'; + handler: (event: { + author: AuthorData; + }) => EntryData | undefined | null | void | Promise; +} + +export interface LogoutEventListener { + name: 'logout'; + handler: () => void | Promise; +} + +export interface ChangeEventListener { + name: 'change'; + collection: string; + file?: string; + field: string; + handler: (event: { + data: EntryData; + collection: string; + field: string; + }) => EntryData | undefined | null | void | Promise; +} + +export type EventListener = + | PreSaveEventListener + | PostSaveEventListener + | ChangeEventListener + | LoginEventListener + | LogoutEventListener + | MountedEventListener; + export interface AdditionalLinkOptions { icon?: string; } diff --git a/packages/core/src/lib/hooks/useDebouncedCallback.ts b/packages/core/src/lib/hooks/useDebouncedCallback.ts index 59d4b070..2a364308 100644 --- a/packages/core/src/lib/hooks/useDebouncedCallback.ts +++ b/packages/core/src/lib/hooks/useDebouncedCallback.ts @@ -1,25 +1,39 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const useDebouncedCallback = any>(func: T, wait: number) => { - // Use a ref to store the timeout between renders - // and prevent changes to it from causing re-renders - const timeout = useRef(); +export default function useDebouncedCallback( + callback: (...args: A) => T, + wait: number, +) { + // track args & timeout handle between calls + const argsRef = useRef(); + const timeout = useRef>(); + + function cleanup() { + if (timeout.current) { + clearTimeout(timeout.current); + } + } + + // make sure our timeout gets cleared if + // our consuming component gets unmounted + useEffect(() => cleanup, []); return useCallback( - (...args: Parameters) => { - const later = () => { - clearTimeout(timeout.current); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - func(...args); - }; + function debouncedCallback(...args: A) { + // capture latest args + argsRef.current = args; - clearTimeout(timeout.current); - timeout.current = setTimeout(later, wait); + // clear debounce timer + cleanup(); + + // start waiting again + timeout.current = setTimeout(() => { + if (argsRef.current) { + callback(...argsRef.current); + } + }, wait); }, - [func, wait], + [callback, wait], ); -}; - -export default useDebouncedCallback; +} diff --git a/packages/core/src/lib/hooks/useEntryCallback.ts b/packages/core/src/lib/hooks/useEntryCallback.ts new file mode 100644 index 00000000..19cfb788 --- /dev/null +++ b/packages/core/src/lib/hooks/useEntryCallback.ts @@ -0,0 +1,112 @@ +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import { useCallback, useEffect, useState } from 'react'; + +import { updateDraft } from '@staticcms/core/actions/entries'; +import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; +import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks'; +import { invokeEvent } from '../registry'; +import { fileForEntry } from '../util/collection.util'; +import useDebouncedCallback from './useDebouncedCallback'; + +import type { Collection, EntryData, Field } from '@staticcms/core/interface'; + +async function handleChange( + path: string[], + collection: string, + field: Field, + entry: EntryData, + oldEntry: EntryData, +): Promise { + const oldValue = get(oldEntry, path); + const newValue = get(entry, path); + + let newEntry = cloneDeep(entry); + + if (!isEqual(oldValue, newValue)) { + newEntry = await invokeEvent({ name: 'change', collection, field: field.name, data: newEntry }); + } + + if ('fields' in field && field.fields) { + for (const childField of field.fields) { + newEntry = await handleChange( + [...path, childField.name], + collection, + childField, + newEntry, + oldEntry, + ); + } + } + + return newEntry; +} + +interface EntryCallbackProps { + hasChanged: boolean; + collection: Collection; + slug: string | undefined; + callback: () => void; +} + +export default function useEntryCallback({ + hasChanged, + slug, + collection, + callback, +}: EntryCallbackProps) { + const dispatch = useAppDispatch(); + + const entry = useAppSelector(selectEditingDraft); + const [lastEntryData, setLastEntryData] = useState(cloneDeep(entry?.data)); + + const runUpdateCheck = useCallback(async () => { + if (isEqual(lastEntryData, entry?.data)) { + return; + } + + if (hasChanged && entry) { + const file = fileForEntry(collection, slug); + let updatedEntryData = entry.data; + + if (file) { + for (const field of file.fields) { + updatedEntryData = await handleChange( + [field.name], + collection.name, + field, + updatedEntryData, + lastEntryData, + ); + } + } else if ('fields' in collection) { + for (const field of collection.fields) { + updatedEntryData = await handleChange( + [field.name], + collection.name, + field, + updatedEntryData, + lastEntryData, + ); + } + } + + if (!isEqual(updatedEntryData, entry.data)) { + setLastEntryData(updatedEntryData); + dispatch(updateDraft({ data: updatedEntryData })); + callback(); + return; + } + + setLastEntryData(entry?.data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entry, hasChanged]); + + const debouncedRunUpdateCheck = useDebouncedCallback(runUpdateCheck, 500); + + useEffect(() => { + debouncedRunUpdateCheck(); + }, [debouncedRunUpdateCheck]); +} diff --git a/packages/core/src/lib/registry.ts b/packages/core/src/lib/registry.ts index 08627d2a..b329b130 100644 --- a/packages/core/src/lib/registry.ts +++ b/packages/core/src/lib/registry.ts @@ -1,4 +1,5 @@ import { oneLine } from 'common-tags'; +import cloneDeep from 'lodash/cloneDeep'; import type { AdditionalLink, @@ -7,6 +8,7 @@ import type { BackendInitializer, BackendInitializerOptions, BaseField, + ChangeEventListener, Collection, Config, CustomIcon, @@ -16,12 +18,12 @@ import type { EventListener, FieldPreviewComponent, LocalePhrasesRoot, - LoginEventHandler, - LogoutEventHandler, - MountedEventHandler, + LoginEventListener, + LogoutEventListener, + MountedEventListener, ObjectValue, - PostSaveEventHandler, - PreSaveEventHandler, + PostSaveEventListener, + PreSaveEventListener, PreviewStyle, PreviewStyleOptions, ShortcodeConfig, @@ -34,19 +36,49 @@ import type { WidgetValueSerializer, } from '../interface'; -export const allowedEvents = ['mounted', 'login', 'logout', 'preSave', 'postSave'] as const; +export const allowedEvents = [ + 'mounted', + 'login', + 'logout', + 'preSave', + 'postSave', + 'change', +] as const; export type AllowedEvent = (typeof allowedEvents)[number]; type EventHandlerRegistry = { - preSave: { handler: PreSaveEventHandler; options: Record }[]; - postSave: { handler: PostSaveEventHandler; options: Record }[]; - mounted: { handler: MountedEventHandler; options: Record }[]; - login: { handler: LoginEventHandler; options: Record }[]; - logout: { handler: LogoutEventHandler; options: Record }[]; + preSave: Record< + string, + PreSaveEventListener['handler'][] | Record + >; + postSave: Record< + string, + PostSaveEventListener['handler'][] | Record + >; + mounted: MountedEventListener['handler'][]; + login: LoginEventListener['handler'][]; + logout: LogoutEventListener['handler'][]; + change: Record< + string, + Record< + string, + ChangeEventListener['handler'][] | Record + > + >; }; const eventHandlers = allowedEvents.reduce((acc, e) => { - acc[e] = []; + switch (e) { + case 'preSave': + case 'postSave': + case 'change': + acc[e] = {}; + break; + default: + acc[e] = []; + break; + } + return acc; }, {} as EventHandlerRegistry); @@ -340,41 +372,158 @@ function validateEventName(name: AllowedEvent) { } } -export function getEventListeners(name: AllowedEvent) { +export function getEventListeners(options: { + name: AllowedEvent; + collection?: string; + field?: string; +}) { + const { name } = options; + validateEventName(name); + + if (name === 'change') { + if (!options.field || !options.collection) { + return []; + } + + return (registry.eventHandlers[name][options.collection] ?? {})[options.field] ?? []; + } + + if (name === 'preSave' || name === 'postSave') { + if (!options.collection) { + return []; + } + + return registry.eventHandlers[name][options.collection] ?? []; + } + return [...registry.eventHandlers[name]]; } -export function registerEventListener< - E extends AllowedEvent, - O extends Record = Record, ->({ name, handler }: EventListener, options?: O) { +export function registerEventListener(listener: EventListener) { + const { name, handler } = listener; validateEventName(name); - registry.eventHandlers[name].push({ - handler: handler as MountedEventHandler & - LoginEventHandler & - PreSaveEventHandler & - PostSaveEventHandler, - options: options ?? {}, - }); + + if (name === 'change') { + const collection = listener.collection; + const file = listener.file; + const field = listener.field; + + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name][collection] = {}; + } + + if (file) { + if (!(file in registry.eventHandlers[name][collection])) { + registry.eventHandlers[name][collection][file] = {}; + } + + if (Array.isArray(registry.eventHandlers[name][collection][file])) { + return; + } + + if (!(field in registry.eventHandlers[name][collection][file])) { + ( + registry.eventHandlers[name][collection][file] as Record< + string, + ChangeEventListener['handler'][] + > + )[field] = []; + } + + ( + registry.eventHandlers[name][collection][file] as Record< + string, + ChangeEventListener['handler'][] + > + )[field].push(handler); + return; + } + + if (!(field in registry.eventHandlers[name][collection])) { + registry.eventHandlers[name][collection][field] = []; + } + + if (!Array.isArray(registry.eventHandlers[name][collection][field])) { + return; + } + + (registry.eventHandlers[name][collection][field] as ChangeEventListener['handler'][]).push( + handler, + ); + return; + } + + if (name === 'preSave' || name === 'postSave') { + const collection = listener.collection; + const file = listener.file; + + if (file) { + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name][collection] = {}; + } + + if (!(file in registry.eventHandlers[name][collection])) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registry.eventHandlers[name][collection] as Record)[file] = []; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registry.eventHandlers[name][collection] as Record)[file].push(handler); + return; + } + + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name][collection] = []; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registry.eventHandlers[name][collection] as any[]).push(handler); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registry.eventHandlers[name].push(handler as any); } -export async function invokeEvent(name: 'login', data: AuthorData): Promise; -export async function invokeEvent(name: 'logout'): Promise; -export async function invokeEvent(name: 'preSave', data: EventData): Promise; -export async function invokeEvent(name: 'postSave', data: EventData): Promise; -export async function invokeEvent(name: 'mounted'): Promise; -export async function invokeEvent( - name: AllowedEvent, - data?: EventData | AuthorData, -): Promise { +export async function invokeEvent(event: { name: 'login'; data: AuthorData }): Promise; +export async function invokeEvent(event: { name: 'logout' }): Promise; +export async function invokeEvent(event: { + name: 'preSave'; + data: EventData; + collection: string; + file?: string; +}): Promise; +export async function invokeEvent(event: { + name: 'postSave'; + data: EventData; + collection: string; + file?: string; +}): Promise; +export async function invokeEvent(event: { name: 'mounted' }): Promise; +export async function invokeEvent(event: { + name: 'change'; + data: EntryData | undefined; + collection: string; + file?: string; + field: string; +}): Promise; +export async function invokeEvent(event: { + name: AllowedEvent; + data?: EventData | EntryData | AuthorData; + collection?: string; + file?: string; + field?: string; +}): Promise { + const { name, data, collection, field } = event; + validateEventName(name); if (name === 'mounted' || name === 'logout') { console.info(`[StaticCMS] Firing ${name} event`); const handlers = registry.eventHandlers[name]; - for (const { handler, options } of handlers) { - handler(options); + for (const handler of handlers) { + handler(); } return; @@ -383,30 +532,120 @@ export async function invokeEvent( if (name === 'login') { console.info('[StaticCMS] Firing login event', data); const handlers = registry.eventHandlers[name]; - for (const { handler, options } of handlers) { - handler(data as AuthorData, options); + for (const handler of handlers) { + handler({ author: data as AuthorData }); } return; } if (name === 'postSave') { - console.info(`[StaticCMS] Firing post save event`, data); - const handlers = registry.eventHandlers[name]; - for (const { handler, options } of handlers) { - handler(data as EventData, options); + if (!collection) { + return; + } + + console.info( + `[StaticCMS] Firing post save event for${ + event.file ? ` "${event.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 }); } return; } - const handlers = registry.eventHandlers[name]; + if (name === 'change') { + if (!collection || !field || !data) { + return; + } - console.info(`[StaticCMS] Firing pre save event`, data); + let _data = cloneDeep(data as EntryData); + console.info( + `[StaticCMS] Firing change event for field "${field}" for${ + event.file ? ` "${event.file}" file in` : '' + } "${collection}" collection, new value:`, + data, + ); + const collectionHandlers = registry.eventHandlers[name][collection] ?? {}; - let _data = { ...(data as EventData) }; - for (const { handler, options } of handlers) { - const result = await handler(_data, options); + 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 }); + if (_data !== undefined && result) { + _data = result; + } + } + + return _data; + } + + if (!collection) { + return; + } + + let _data = cloneDeep(data as EventData); + console.info( + `[StaticCMS] Firing pre save event for${ + event.file ? ` "${event.file}" file in` : '' + } "${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 }); if (_data !== undefined && result !== undefined) { const entry = { ..._data.entry, @@ -419,15 +658,91 @@ export async function invokeEvent( return _data.entry.data; } -export function removeEventListener({ name, handler }: EventListener) { +export function removeEventListener(listener: EventListener) { + const { name, handler } = listener; + validateEventName(name); - if (handler) { - registry.eventHandlers[name] = registry.eventHandlers[name].filter( - item => item.handler !== handler, + if (name === 'change') { + const collection = listener.collection; + const file = listener.file; + const field = listener.field; + + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name][collection] = {}; + } + + if (file) { + if (!(file in registry.eventHandlers[name][collection])) { + registry.eventHandlers[name][collection][file] = {}; + } + + if (!(field in registry.eventHandlers[name][collection][file])) { + ( + registry.eventHandlers[name][collection][file] as Record< + string, + ChangeEventListener['handler'][] + > + )[field] = []; + } + + ( + registry.eventHandlers[name][collection][file] as Record< + string, + ChangeEventListener['handler'][] + > + )[field].filter(item => item !== handler); + + return; + } + + if (!(field in registry.eventHandlers[name][collection])) { + registry.eventHandlers[name][collection][field] = []; + } + + if (!Array.isArray(registry.eventHandlers[name][collection][field])) { + return; + } + + (registry.eventHandlers[name][collection][field] as ChangeEventListener['handler'][]).filter( + item => item !== handler, ); - } else { - registry.eventHandlers[name] = []; + return; } + + if (name === 'preSave' || name === 'postSave') { + const collection = listener.collection; + const file = listener.file; + + if (file) { + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name][collection] = {}; + } + + if (!(file in registry.eventHandlers[name][collection])) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registry.eventHandlers[name][collection] as Record)[file] = []; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registry.eventHandlers[name][collection] as Record)[file].filter( + item => item !== handler, + ); + return; + } + + if (!(collection in registry.eventHandlers[name])) { + registry.eventHandlers[name][collection] = []; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (registry.eventHandlers[name][collection] as any[]).filter(item => item !== handler); + return; + } + + registry.eventHandlers[name] = registry.eventHandlers[name].filter( + item => item !== handler, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any; } /** diff --git a/packages/core/src/reducers/entryDraft.ts b/packages/core/src/reducers/entryDraft.ts index 7739c002..8879d500 100644 --- a/packages/core/src/reducers/entryDraft.ts +++ b/packages/core/src/reducers/entryDraft.ts @@ -11,6 +11,7 @@ import { DRAFT_DISCARD, DRAFT_LOCAL_BACKUP_DELETE, DRAFT_LOCAL_BACKUP_RETRIEVED, + DRAFT_UPDATE, DRAFT_VALIDATION_ERRORS, ENTRY_DELETE_SUCCESS, ENTRY_PERSIST_FAILURE, @@ -147,6 +148,39 @@ function entryDraftReducer( return newState; } + case DRAFT_UPDATE: { + let newState = { ...state }; + if (!newState.entry) { + return state; + } + + const { data } = action.payload; + + newState = { + ...newState, + entry: { + ...newState.entry, + data, + }, + }; + + let hasChanged = + !isEqual(newState.entry?.meta, newState.original?.meta) || + !isEqual(newState.entry?.data, newState.original?.data); + + const i18nData = newState.entry?.i18n ?? {}; + for (const locale in i18nData) { + hasChanged = + hasChanged || + !isEqual(newState.entry?.i18n?.[locale]?.data, newState.original?.i18n?.[locale]?.data); + } + + return { + ...newState, + hasChanged: !newState.original || hasChanged, + }; + } + case DRAFT_CHANGE_FIELD: { let newState = { ...state }; if (!newState.entry) { diff --git a/packages/core/src/widgets/list/DelimitedListControl.tsx b/packages/core/src/widgets/list/DelimitedListControl.tsx index f8ab2406..fae0f997 100644 --- a/packages/core/src/widgets/list/DelimitedListControl.tsx +++ b/packages/core/src/widgets/list/DelimitedListControl.tsx @@ -27,7 +27,7 @@ const DelimitedListControl: FC (controlled || duplicate ? rawValue : internalRawValue), [controlled, duplicate, rawValue, internalRawValue], ); - const debouncedInternalValue = useDebounce(internalValue, 200); + const debouncedInternalValue = useDebounce(internalValue, 250); const ref = useRef(null); diff --git a/packages/core/src/widgets/markdown/MarkdownPreview.tsx b/packages/core/src/widgets/markdown/MarkdownPreview.tsx index 376cb16b..e5a2f80e 100644 --- a/packages/core/src/widgets/markdown/MarkdownPreview.tsx +++ b/packages/core/src/widgets/markdown/MarkdownPreview.tsx @@ -60,7 +60,8 @@ const MarkdownPreview: FC> = previewPr setPrevValue(parsedValue); setValue(parsedValue); } - }, [prevValue, setValue, value]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); return (
diff --git a/packages/core/src/widgets/string/StringControl.tsx b/packages/core/src/widgets/string/StringControl.tsx index 98e73572..d3842e5c 100644 --- a/packages/core/src/widgets/string/StringControl.tsx +++ b/packages/core/src/widgets/string/StringControl.tsx @@ -24,7 +24,7 @@ const StringControl: FC> = ({ () => (controlled || duplicate ? rawValue : internalRawValue), [controlled, duplicate, rawValue, internalRawValue], ); - const debouncedInternalValue = useDebounce(internalValue, 200); + const debouncedInternalValue = useDebounce(internalValue, 250); const ref = useRef(null); diff --git a/packages/core/src/widgets/text/TextControl.tsx b/packages/core/src/widgets/text/TextControl.tsx index 1a77d8d3..c9cdb27c 100644 --- a/packages/core/src/widgets/text/TextControl.tsx +++ b/packages/core/src/widgets/text/TextControl.tsx @@ -24,7 +24,7 @@ const TextControl: FC> = ({ () => (duplicate ? rawValue : internalRawValue), [internalRawValue, duplicate, rawValue], ); - const debouncedInternalValue = useDebounce(internalValue, 200); + const debouncedInternalValue = useDebounce(internalValue, 250); const ref = useRef(null); diff --git a/packages/docs/content/docs/beta-features.mdx b/packages/docs/content/docs/beta-features.mdx index dfa5d36d..3fc0f231 100644 --- a/packages/docs/content/docs/beta-features.mdx +++ b/packages/docs/content/docs/beta-features.mdx @@ -92,7 +92,11 @@ CMS.registerEventListener({ }); ``` -Supported events are `mounted`, `login`, `logout`, `preSave` and `postSave`. The `preSave` hook can be used to modify the entry data like so: +Supported events are `mounted`, `login`, `logout`, `change`, `preSave` and `postSave`. + +### PreSave + +The `preSave` event handler can be used to modify the entry data like so: ```javascript CMS.registerEventListener({ @@ -109,6 +113,24 @@ CMS.registerEventListener({ }); ``` +### Change + +The `change` event handler must provide a field name, and can be used to modify the entry data like so: + +```javascript +CMS.registerEventListener( + { + name: 'change', + handler: ({ entry }, { field }) => { + return 'newFieldValue'; + }, + }, + { + field: 'path.to.my.field', + }, +); +``` + ## i18n Support Static CMS can provide a side by side interface for authoring content in multiple languages. Configuring Static CMS for i18n support requires top level configuration, collection level configuration and field level configuration. diff --git a/packages/docs/content/docs/collection-types.mdx b/packages/docs/content/docs/collection-types.mdx index a3649328..d2f71906 100644 --- a/packages/docs/content/docs/collection-types.mdx +++ b/packages/docs/content/docs/collection-types.mdx @@ -230,6 +230,79 @@ collections: [ +##### Filtered by Nested Field + +The example below creates a collection based on a nested field's value. + + +```yaml +collections: + - name: 'nested-field-filtered-collection' + label: 'Nested Field Filtered Collection' + folder: '_nested_field' + create: true + filter: + field: nested.object.field + value: yes + fields: + - name: nested + label: Nested + widget: object + fields: + - name: object + label: Object + widget: object + fields: + - name: field + label: Field + widget: select + options: + - yes + - no +``` + +```js +collections: [ + { + name: "nested-field-filtered-collection", + label: "Nested Field Filtered Collection", + folder: "_nested_field", + create: true, + filter: { + field: "nested.object.field", + value: "yes" + }, + fields: [ + { + name: "nested", + label: "Nested", + widget: "object", + fields: [ + { + name: "object", + label: "Object", + widget: "object", + fields: [ + { + name: "field", + label: "Field", + widget: "select", + options: [ + "yes", + "no" + ] + } + ] + } + ] + } + ] + } +], +``` + + + ##### Filtered by Tags The example below creates two collections in the same folder, filtered by the `tags` field. The first collection includes posts with the `news` or `article` tags, and the second, with the `blog` tag. diff --git a/packages/docs/content/docs/widgets.mdx b/packages/docs/content/docs/widgets.mdx index 8eb259e6..5a619b67 100644 --- a/packages/docs/content/docs/widgets.mdx +++ b/packages/docs/content/docs/widgets.mdx @@ -47,20 +47,7 @@ The following options are available on all fields: | i18n | boolean
\| 'translate'
\| 'duplicate'
\| 'none' | | _Optional_.
  • `translate` - Allows translation of the field
  • `duplicate` - Duplicates the value from the default locale
  • `true` - Accept parent values as default
  • `none` or `false` - Exclude field from translations
| | condition | FilterRule
\| List of FilterRules | | _Optional_. See [Field Conditions](#field-conditions) | -## Field Conditions - -The fields can be shown conditionally based on the values of the other fields. - -The `condition` option can take a single filter rule or a list of filter rules. - -| Name | Type | Default | Description | -| -------- | ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| field | string | | The name of one of the field. | -| value | string
\| list of strings | | _Optional_. The desired value or values to match. Required if no `pattern` provided. Ignored if `pattern` is provided | -| pattern | regular expression | | _Optional_. A regex pattern to match against the field's value | -| matchAll | boolean | `false` | _Optional_. _Ignored if value is not a list of strings_
  • `true` - The field's values must include or match all of the filter rule's values
  • `false` - The field's value must include or match only one of the filter rule's values
| - -## Example +## Example Widget ```yaml @@ -78,3 +65,179 @@ pattern: ['.{12,}', 'Must have at least 12 characters'], ``` + +## Field Conditions + +The fields can be shown conditionally based on the values of the other fields. + +The `condition` option can take a single filter rule or a list of filter rules. + +| Name | Type | Default | Description | +| -------- | ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| field | string | | The name of one of the field. | +| value | string
\| list of strings | | _Optional_. The desired value or values to match. Required if no `pattern` provided. Ignored if `pattern` is provided | +| pattern | regular expression | | _Optional_. A regex pattern to match against the field's value | +| matchAll | boolean | `false` | _Optional_. _Ignored if value is not a list of strings_
  • `true` - The field's values must include or match all of the filter rule's values
  • `false` - The field's value must include or match only one of the filter rule's values
| + +### Example + +The example below creates a collection based on a nested field's value. + + +```yaml +collections: + - name: 'nested-field-filtered-collection' + label: 'Nested Field Filtered Collection' + folder: '_field_condition' + create: true + fields: + - name: type + label: Type + widget: select + options: + - value: type1 + label: Type 1 + - value: type2 + label: Type 2 + - name: value1 + label: Value 1 + widget: string + condition: + field: type + value: type1 + - name: value2 + label: Value 2 + widget: text + condition: + field: type + value: type2 +``` + +```js +collections: [ + { + name: "nested-field-filtered-collection", + label: "Nested Field Filtered Collection", + folder: "_field_condition", + create: true, + fields: [ + { + name: "type", + label: "Type", + widget: "select", + options: [ + { + value: "type1", + label: "Type 1" + }, + { + value: "type2", + label: "Type 2" + } + ] + }, + { + name: "value1", + label: "Value 1", + widget: "string", + condition: { + field: "type", + value: "type1" + } + }, + { + name: "value2", + label: "Value 2", + widget: "text", + condition: { + field: "type", + value: "type2" + } + } + ] + } +], +``` + + + +### Nested Field Example + +The example below creates a collection based on a nested field's value. + + +```yaml +collections: + - name: 'nested-field-filtered-collection' + label: 'Nested Field Filtered Collection' + folder: '_nested_field_condition' + create: true + fields: + - name: value + label: Value 1 + widget: string + condition: + field: nested.object.field + value: yes + - name: nested + label: Nested + widget: object + fields: + - name: object + label: Object + widget: object + fields: + - name: field + label: Field + widget: select + options: + - yes + - no +``` + +```js +collections: [ + { + name: "nested-field-filtered-collection", + label: "Nested Field Filtered Collection", + folder: "_nested_field_condition", + create: true, + fields: [ + { + name: "value", + label: "Value 1", + widget: "string", + condition: { + field: "nested.object.field", + value: "yes" + } + }, + { + name: "nested", + label: "Nested", + widget: "object", + fields: [ + { + name: "object", + label: "Object", + widget: "object", + fields: [ + { + name: "field", + label: "Field", + widget: "select", + options: [ + "yes", + "no" + ] + } + ] + } + ] + } + ] + } +], +``` + +