feat: change event (#842)

This commit is contained in:
Daniel Lautzenheiser 2023-07-10 15:49:08 -04:00 committed by GitHub
parent 16819ed7be
commit eff9a62c5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 928 additions and 147 deletions

View File

@ -1,2 +1,5 @@
Breaking changes to be documented for v3
- gitea API config has changed.
- hidden removed as widget parameter
- events

View File

@ -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;

View File

@ -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

View File

@ -859,7 +859,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
usedSlugs,
status,
}: PersistArgs) {
const modifiedData = await this.invokePreSaveEvent(draft.entry);
const modifiedData = await this.invokePreSaveEvent(draft.entry, collection);
const entryDraft = modifiedData
? {
...draft,
@ -950,7 +950,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
opts,
);
await this.invokePostSaveEvent(entryDraft.entry);
await this.invokePostSaveEvent(entryDraft.entry, collection);
return slug;
}
@ -960,14 +960,14 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return { entry, author: { login, name } };
}
async invokePreSaveEvent(entry: Entry): Promise<EntryData> {
async invokePreSaveEvent(entry: Entry, collection: Collection): Promise<EntryData> {
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<void> {
async invokePostSaveEvent(entry: Entry, collection: Collection): Promise<void> {
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) {

View File

@ -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,

View File

@ -188,9 +188,12 @@ const App = ({
const [prevUser, setPrevUser] = useState(user);
useEffect(() => {
if (!prevUser && user) {
invokeEvent('login', {
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' });
});
}, []);

View File

@ -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<TranslatedProps<EditorProps>> = ({
};
}, [collection, createBackup, entryDraft.entry, hasChanged]);
useEntryCallback({
hasChanged,
collection,
slug,
callback: () => {
setVersion(version => version + 1);
},
});
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevSlug, setPrevSlug] = useState<string | undefined | null>(null);
useEffect(() => {

View File

@ -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,

View File

@ -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';

View File

@ -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<O extends Record<string, unknown> = Record<string, unknown>> = (
data: EventData,
options: O,
) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
export type PostSaveEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
data: EventData,
options: O,
) => void | Promise<void>;
export type MountedEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
options: O,
) => void | Promise<void>;
export type LoginEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
data: AuthorData,
options: O,
) => void | Promise<void>;
export type LogoutEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
options: O,
) => void | Promise<void>;
export type EventHandlers<O extends Record<string, unknown> = Record<string, unknown>> = {
preSave: PreSaveEventHandler<O>;
postSave: PostSaveEventHandler<O>;
mounted: MountedEventHandler<O>;
login: LoginEventHandler<O>;
logout: LogoutEventHandler<O>;
};
export interface EventListener<
E extends AllowedEvent = 'mounted',
O extends Record<string, unknown> = Record<string, unknown>,
> {
name: E;
handler: EventHandlers<O>[E];
export interface PreSaveEventListener {
name: 'preSave';
collection: string;
file?: string;
handler: (event: {
data: EventData;
collection: string;
}) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
}
export interface PostSaveEventListener {
name: 'postSave';
collection: string;
file?: string;
handler: (event: { data: EventData; collection: string }) => void | Promise<void>;
}
export interface MountedEventListener {
name: 'mounted';
handler: () => void | Promise<void>;
}
export interface LoginEventListener {
name: 'login';
handler: (event: {
author: AuthorData;
}) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
}
export interface LogoutEventListener {
name: 'logout';
handler: () => void | Promise<void>;
}
export interface ChangeEventListener {
name: 'change';
collection: string;
file?: string;
field: string;
handler: (event: {
data: EntryData;
collection: string;
field: string;
}) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
}
export type EventListener =
| PreSaveEventListener
| PostSaveEventListener
| ChangeEventListener
| LoginEventListener
| LogoutEventListener
| MountedEventListener;
export interface AdditionalLinkOptions {
icon?: string;
}

View File

@ -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 = <T extends (...args: any) => 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<NodeJS.Timeout>();
export default function useDebouncedCallback<A extends any[], T = void>(
callback: (...args: A) => T,
wait: number,
) {
// track args & timeout handle between calls
const argsRef = useRef<A>();
const timeout = useRef<ReturnType<typeof setTimeout>>();
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<T>) => {
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;
}

View File

@ -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<EntryData> {
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<EntryData>(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]);
}

View File

@ -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<string, unknown> }[];
postSave: { handler: PostSaveEventHandler; options: Record<string, unknown> }[];
mounted: { handler: MountedEventHandler; options: Record<string, unknown> }[];
login: { handler: LoginEventHandler; options: Record<string, unknown> }[];
logout: { handler: LogoutEventHandler; options: Record<string, unknown> }[];
preSave: Record<
string,
PreSaveEventListener['handler'][] | Record<string, PreSaveEventListener['handler'][]>
>;
postSave: Record<
string,
PostSaveEventListener['handler'][] | Record<string, PostSaveEventListener['handler'][]>
>;
mounted: MountedEventListener['handler'][];
login: LoginEventListener['handler'][];
logout: LogoutEventListener['handler'][];
change: Record<
string,
Record<
string,
ChangeEventListener['handler'][] | Record<string, ChangeEventListener['handler'][]>
>
>;
};
const eventHandlers = allowedEvents.reduce((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<string, unknown> = Record<string, unknown>,
>({ name, handler }: EventListener<E, O>, 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] = {};
}
export async function invokeEvent(name: 'login', data: AuthorData): Promise<void>;
export async function invokeEvent(name: 'logout'): Promise<void>;
export async function invokeEvent(name: 'preSave', data: EventData): Promise<EntryData>;
export async function invokeEvent(name: 'postSave', data: EventData): Promise<void>;
export async function invokeEvent(name: 'mounted'): Promise<void>;
export async function invokeEvent(
name: AllowedEvent,
data?: EventData | AuthorData,
): Promise<void | EntryData> {
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<string, any[]>)[file] = [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(registry.eventHandlers[name][collection] as Record<string, any[]>)[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(event: { name: 'login'; data: AuthorData }): Promise<void>;
export async function invokeEvent(event: { name: 'logout' }): Promise<void>;
export async function invokeEvent(event: {
name: 'preSave';
data: EventData;
collection: string;
file?: string;
}): Promise<EntryData>;
export async function invokeEvent(event: {
name: 'postSave';
data: EventData;
collection: string;
file?: string;
}): Promise<void>;
export async function invokeEvent(event: { name: 'mounted' }): Promise<void>;
export async function invokeEvent(event: {
name: 'change';
data: EntryData | undefined;
collection: string;
file?: string;
field: string;
}): Promise<EntryData>;
export async function invokeEvent(event: {
name: AllowedEvent;
data?: EventData | EntryData | AuthorData;
collection?: string;
file?: string;
field?: string;
}): Promise<void | EntryData> {
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<string, ChangeEventListener['handler'][]>;
if (
event.file &&
event.file in collectionHandlers &&
!Array.isArray(collectionHandlers[event.file])
) {
finalHandlers =
(collectionHandlers as Record<string, Record<string, ChangeEventListener['handler'][]>>)[
event.file
] ?? {};
} else if (Array.isArray(collectionHandlers[field])) {
finalHandlers = collectionHandlers as Record<string, ChangeEventListener['handler'][]>;
} 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,
);
} else {
registry.eventHandlers[name] = [];
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,
);
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<string, any[]>)[file] = [];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(registry.eventHandlers[name][collection] as Record<string, any[]>)[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;
}
/**

View File

@ -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) {

View File

@ -27,7 +27,7 @@ const DelimitedListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListFiel
() => (controlled || duplicate ? rawValue : internalRawValue),
[controlled, duplicate, rawValue, internalRawValue],
);
const debouncedInternalValue = useDebounce(internalValue, 200);
const debouncedInternalValue = useDebounce(internalValue, 250);
const ref = useRef<HTMLInputElement | null>(null);

View File

@ -60,7 +60,8 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
setPrevValue(parsedValue);
setValue(parsedValue);
}
}, [prevValue, setValue, value]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<div key="markdown-preview">

View File

@ -24,7 +24,7 @@ const StringControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
() => (controlled || duplicate ? rawValue : internalRawValue),
[controlled, duplicate, rawValue, internalRawValue],
);
const debouncedInternalValue = useDebounce(internalValue, 200);
const debouncedInternalValue = useDebounce(internalValue, 250);
const ref = useRef<HTMLInputElement | null>(null);

View File

@ -24,7 +24,7 @@ const TextControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
() => (duplicate ? rawValue : internalRawValue),
[internalRawValue, duplicate, rawValue],
);
const debouncedInternalValue = useDebounce(internalValue, 200);
const debouncedInternalValue = useDebounce(internalValue, 250);
const ref = useRef<HTMLInputElement | null>(null);

View File

@ -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.

View File

@ -230,6 +230,79 @@ collections: [
</CodeTabs>
##### Filtered by Nested Field
The example below creates a collection based on a nested field's value.
<CodeTabs>
```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"
]
}
]
}
]
}
]
}
],
```
</CodeTabs>
##### 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.

View File

@ -47,20 +47,7 @@ The following options are available on all fields:
| i18n | boolean<br />\| 'translate'<br />\| 'duplicate'<br />\| 'none' | | _Optional_. <BetaImage /><ul><li>`translate` - Allows translation of the field</li><li>`duplicate` - Duplicates the value from the default locale</li><li>`true` - Accept parent values as default</li><li>`none` or `false` - Exclude field from translations</li></ul> |
| condition | FilterRule<br />\| 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<br />\| 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_<br /><ul><li>`true` - The field's values must include or match all of the filter rule's values</li><li>`false` - The field's value must include or match only one of the filter rule's values</li></ul> |
## Example
## Example Widget
<CodeTabs>
```yaml
@ -78,3 +65,179 @@ pattern: ['.{12,}', 'Must have at least 12 characters'],
```
</CodeTabs>
## 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<br />\| 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_<br /><ul><li>`true` - The field's values must include or match all of the filter rule's values</li><li>`false` - The field's value must include or match only one of the filter rule's values</li></ul> |
### Example
The example below creates a collection based on a nested field's value.
<CodeTabs>
```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"
}
}
]
}
],
```
</CodeTabs>
### Nested Field Example
The example below creates a collection based on a nested field's value.
<CodeTabs>
```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"
]
}
]
}
]
}
]
}
],
```
</CodeTabs>