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. - gitea API config has changed.
- hidden removed as widget parameter - hidden removed as widget parameter
- events

View File

@ -1,5 +1,6 @@
import { currentBackend } from '../backend'; import { currentBackend } from '../backend';
import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants'; 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 { addSnackbar } from '../store/slices/snackbars';
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
@ -35,6 +36,8 @@ export function doneAuthenticating() {
} }
export function logout() { export function logout() {
invokeEvent({ name: 'logout' });
return { return {
type: LOGOUT, type: LOGOUT,
} as const; } as const;

View File

@ -4,6 +4,7 @@ import { currentBackend } from '../backend';
import { import {
ADD_DRAFT_ENTRY_MEDIA_FILE, ADD_DRAFT_ENTRY_MEDIA_FILE,
CHANGE_VIEW_STYLE, CHANGE_VIEW_STYLE,
DRAFT_UPDATE,
DRAFT_CHANGE_FIELD, DRAFT_CHANGE_FIELD,
DRAFT_CREATE_DUPLICATE_FROM_ENTRY, DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
DRAFT_CREATE_EMPTY, DRAFT_CREATE_EMPTY,
@ -458,6 +459,13 @@ export function discardDraft() {
return { type: DRAFT_DISCARD } as const; return { type: DRAFT_DISCARD } as const;
} }
export function updateDraft({ data }: { data: EntryData }) {
return {
type: DRAFT_UPDATE,
payload: { data },
} as const;
}
export function changeDraftField({ export function changeDraftField({
path, path,
field, field,
@ -1125,6 +1133,7 @@ export type EntriesAction = ReturnType<
| typeof createDraftFromEntry | typeof createDraftFromEntry
| typeof draftDuplicateEntry | typeof draftDuplicateEntry
| typeof discardDraft | typeof discardDraft
| typeof updateDraft
| typeof changeDraftField | typeof changeDraftField
| typeof changeDraftFieldValidation | typeof changeDraftFieldValidation
| typeof localBackupRetrieved | typeof localBackupRetrieved

View File

@ -859,7 +859,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
usedSlugs, usedSlugs,
status, status,
}: PersistArgs) { }: PersistArgs) {
const modifiedData = await this.invokePreSaveEvent(draft.entry); const modifiedData = await this.invokePreSaveEvent(draft.entry, collection);
const entryDraft = modifiedData const entryDraft = modifiedData
? { ? {
...draft, ...draft,
@ -950,7 +950,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
opts, opts,
); );
await this.invokePostSaveEvent(entryDraft.entry); await this.invokePostSaveEvent(entryDraft.entry, collection);
return slug; return slug;
} }
@ -960,14 +960,14 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return { entry, author: { login, name } }; 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); 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); 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) { 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.'); throw new Error('Your Gitea user account does not have access to this repo.');
} }
console.log(user);
// Authorized user // Authorized user
return { return {
name: user.full_name, name: user.full_name,

View File

@ -188,9 +188,12 @@ const App = ({
const [prevUser, setPrevUser] = useState(user); const [prevUser, setPrevUser] = useState(user);
useEffect(() => { useEffect(() => {
if (!prevUser && user) { if (!prevUser && user) {
invokeEvent('login', { invokeEvent({
name: 'login',
data: {
login: user.login, login: user.login,
name: user.name ?? '', name: user.name ?? '',
},
}); });
} }
setPrevUser(user); setPrevUser(user);
@ -243,7 +246,7 @@ const App = ({
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
invokeEvent('mounted'); invokeEvent({ name: 'mounted' });
}); });
}, []); }, []);

View File

@ -20,6 +20,7 @@ import {
} from '@staticcms/core/actions/entries'; } from '@staticcms/core/actions/entries';
import { loadMedia } from '@staticcms/core/actions/mediaLibrary'; import { loadMedia } from '@staticcms/core/actions/mediaLibrary';
import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll'; 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 { selectFields } from '@staticcms/core/lib/util/collection.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util'; import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config'; import { selectConfig } from '@staticcms/core/reducers/selectors/config';
@ -210,6 +211,15 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
}; };
}, [collection, createBackup, entryDraft.entry, hasChanged]); }, [collection, createBackup, entryDraft.entry, hasChanged]);
useEntryCallback({
hasChanged,
collection,
slug,
callback: () => {
setVersion(version => version + 1);
},
});
const [prevCollection, setPrevCollection] = useState<Collection | null>(null); const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevSlug, setPrevSlug] = useState<string | undefined | null>(null); const [prevSlug, setPrevSlug] = useState<string | undefined | null>(null);
useEffect(() => { useEffect(() => {

View File

@ -13,6 +13,7 @@ import {
removeInsertedMedia as removeInsertedMediaAction, removeInsertedMedia as removeInsertedMediaAction,
} from '@staticcms/core/actions/mediaLibrary'; } from '@staticcms/core/actions/mediaLibrary';
import { query as queryAction } from '@staticcms/core/actions/search'; 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 useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n'; import { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n';
@ -134,15 +135,18 @@ const EditorControl = ({
]); ]);
const handleChangeDraftField = useCallback( const handleChangeDraftField = useCallback(
(value: ValueOrNestedValue) => { async (value: ValueOrNestedValue) => {
setDirty( setDirty(
oldDirty => oldDirty || !isEmpty(widget.getValidValue(value, field as UnknownField)), oldDirty => oldDirty || !isEmpty(widget.getValidValue(value, field as UnknownField)),
); );
changeDraftField({ path, field, value, i18n, isMeta }); changeDraftField({ path, field, value, i18n, isMeta });
}, },
[changeDraftField, field, i18n, isMeta, path, widget], [changeDraftField, field, i18n, isMeta, path, widget],
); );
const handleDebouncedChangeDraftField = useDebouncedCallback(handleChangeDraftField, 250);
const config = useMemo(() => configState.config, [configState.config]); const config = useMemo(() => configState.config, [configState.config]);
const finalValue = useMemoCompare(value, isEqual); const finalValue = useMemoCompare(value, isEqual);
@ -155,21 +159,23 @@ const EditorControl = ({
if ('default' in field && isNotNullish(!field.default)) { if ('default' in field && isNotNullish(!field.default)) {
if (widget.getDefaultValue) { if (widget.getDefaultValue) {
handleChangeDraftField( handleDebouncedChangeDraftField(
widget.getDefaultValue(field.default, field as unknown as UnknownField), widget.getDefaultValue(field.default, field as unknown as UnknownField),
); );
} else { } else {
handleChangeDraftField(field.default); handleDebouncedChangeDraftField(field.default);
} }
setVersion(version => version + 1); setVersion(version => version + 1);
return; return;
} }
if (widget.getDefaultValue) { 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); setVersion(version => version + 1);
} }
}, [field, finalValue, handleChangeDraftField, widget]); }, [field, finalValue, handleDebouncedChangeDraftField, widget]);
return useMemo(() => { return useMemo(() => {
if (!collection || !entry || !config || field.widget === 'hidden') { if (!collection || !entry || !config || field.widget === 'hidden') {
@ -192,7 +198,7 @@ const EditorControl = ({
label: getFieldLabel(field, t), label: getFieldLabel(field, t),
locale, locale,
mediaPaths, mediaPaths,
onChange: handleChangeDraftField, onChange: handleDebouncedChangeDraftField,
openMediaLibrary, openMediaLibrary,
removeInsertedMedia, removeInsertedMedia,
path, path,
@ -225,7 +231,7 @@ const EditorControl = ({
t, t,
locale, locale,
mediaPaths, mediaPaths,
handleChangeDraftField, handleDebouncedChangeDraftField,
openMediaLibrary, openMediaLibrary,
removeInsertedMedia, removeInsertedMedia,
path, 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_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY';
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
export const DRAFT_DISCARD = 'DRAFT_DISCARD'; export const DRAFT_DISCARD = 'DRAFT_DISCARD';
export const DRAFT_UPDATE = 'DRAFT_UPDATE';
export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD'; export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS'; export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED'; export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED';

View File

@ -47,7 +47,6 @@ import type {
I18N_STRUCTURE_MULTIPLE_FOLDERS, I18N_STRUCTURE_MULTIPLE_FOLDERS,
I18N_STRUCTURE_SINGLE_FILE, I18N_STRUCTURE_SINGLE_FILE,
} from './lib/i18n'; } from './lib/i18n';
import type { AllowedEvent } from './lib/registry';
import type Cursor from './lib/util/Cursor'; import type Cursor from './lib/util/Cursor';
import type AssetProxy from './valueObjects/AssetProxy'; import type AssetProxy from './valueObjects/AssetProxy';
@ -922,45 +921,60 @@ export interface EventData {
author: AuthorData; author: AuthorData;
} }
export type PreSaveEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = ( export interface PreSaveEventListener {
data: EventData, name: 'preSave';
options: O, collection: string;
) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>; file?: string;
handler: (event: {
export type PostSaveEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = ( data: EventData;
data: EventData, collection: string;
options: O, }) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
) => 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 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 { export interface AdditionalLinkOptions {
icon?: string; 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const useDebouncedCallback = <T extends (...args: any) => any>(func: T, wait: number) => { export default function useDebouncedCallback<A extends any[], T = void>(
// Use a ref to store the timeout between renders callback: (...args: A) => T,
// and prevent changes to it from causing re-renders wait: number,
const timeout = useRef<NodeJS.Timeout>(); ) {
// 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( return useCallback(
(...args: Parameters<T>) => { function debouncedCallback(...args: A) {
const later = () => { // capture latest args
clearTimeout(timeout.current); argsRef.current = args;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
func(...args);
};
clearTimeout(timeout.current); // clear debounce timer
timeout.current = setTimeout(later, wait); 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 { oneLine } from 'common-tags';
import cloneDeep from 'lodash/cloneDeep';
import type { import type {
AdditionalLink, AdditionalLink,
@ -7,6 +8,7 @@ import type {
BackendInitializer, BackendInitializer,
BackendInitializerOptions, BackendInitializerOptions,
BaseField, BaseField,
ChangeEventListener,
Collection, Collection,
Config, Config,
CustomIcon, CustomIcon,
@ -16,12 +18,12 @@ import type {
EventListener, EventListener,
FieldPreviewComponent, FieldPreviewComponent,
LocalePhrasesRoot, LocalePhrasesRoot,
LoginEventHandler, LoginEventListener,
LogoutEventHandler, LogoutEventListener,
MountedEventHandler, MountedEventListener,
ObjectValue, ObjectValue,
PostSaveEventHandler, PostSaveEventListener,
PreSaveEventHandler, PreSaveEventListener,
PreviewStyle, PreviewStyle,
PreviewStyleOptions, PreviewStyleOptions,
ShortcodeConfig, ShortcodeConfig,
@ -34,19 +36,49 @@ import type {
WidgetValueSerializer, WidgetValueSerializer,
} from '../interface'; } 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]; export type AllowedEvent = (typeof allowedEvents)[number];
type EventHandlerRegistry = { type EventHandlerRegistry = {
preSave: { handler: PreSaveEventHandler; options: Record<string, unknown> }[]; preSave: Record<
postSave: { handler: PostSaveEventHandler; options: Record<string, unknown> }[]; string,
mounted: { handler: MountedEventHandler; options: Record<string, unknown> }[]; PreSaveEventListener['handler'][] | Record<string, PreSaveEventListener['handler'][]>
login: { handler: LoginEventHandler; options: Record<string, unknown> }[]; >;
logout: { handler: LogoutEventHandler; options: Record<string, unknown> }[]; 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) => { const eventHandlers = allowedEvents.reduce((acc, e) => {
switch (e) {
case 'preSave':
case 'postSave':
case 'change':
acc[e] = {};
break;
default:
acc[e] = []; acc[e] = [];
break;
}
return acc; return acc;
}, {} as EventHandlerRegistry); }, {} 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); 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]]; return [...registry.eventHandlers[name]];
} }
export function registerEventListener< export function registerEventListener(listener: EventListener) {
E extends AllowedEvent, const { name, handler } = listener;
O extends Record<string, unknown> = Record<string, unknown>,
>({ name, handler }: EventListener<E, O>, options?: O) {
validateEventName(name); validateEventName(name);
registry.eventHandlers[name].push({
handler: handler as MountedEventHandler & if (name === 'change') {
LoginEventHandler & const collection = listener.collection;
PreSaveEventHandler & const file = listener.file;
PostSaveEventHandler, const field = listener.field;
options: options ?? {},
}); 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<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(name: 'login', data: AuthorData): Promise<void>; export async function invokeEvent(event: { name: 'login'; data: AuthorData }): Promise<void>;
export async function invokeEvent(name: 'logout'): Promise<void>; export async function invokeEvent(event: { name: 'logout' }): Promise<void>;
export async function invokeEvent(name: 'preSave', data: EventData): Promise<EntryData>; export async function invokeEvent(event: {
export async function invokeEvent(name: 'postSave', data: EventData): Promise<void>; name: 'preSave';
export async function invokeEvent(name: 'mounted'): Promise<void>; data: EventData;
export async function invokeEvent( collection: string;
name: AllowedEvent, file?: string;
data?: EventData | AuthorData, }): Promise<EntryData>;
): Promise<void | 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); validateEventName(name);
if (name === 'mounted' || name === 'logout') { if (name === 'mounted' || name === 'logout') {
console.info(`[StaticCMS] Firing ${name} event`); console.info(`[StaticCMS] Firing ${name} event`);
const handlers = registry.eventHandlers[name]; const handlers = registry.eventHandlers[name];
for (const { handler, options } of handlers) { for (const handler of handlers) {
handler(options); handler();
} }
return; return;
@ -383,30 +532,120 @@ export async function invokeEvent(
if (name === 'login') { if (name === 'login') {
console.info('[StaticCMS] Firing login event', data); console.info('[StaticCMS] Firing login event', data);
const handlers = registry.eventHandlers[name]; const handlers = registry.eventHandlers[name];
for (const { handler, options } of handlers) { for (const handler of handlers) {
handler(data as AuthorData, options); handler({ author: data as AuthorData });
} }
return; return;
} }
if (name === 'postSave') { if (name === 'postSave') {
console.info(`[StaticCMS] Firing post save event`, data); if (!collection) {
const handlers = registry.eventHandlers[name]; return;
for (const { handler, options } of handlers) { }
handler(data as EventData, options);
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; 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) }; let finalHandlers: Record<string, ChangeEventListener['handler'][]>;
for (const { handler, options } of handlers) { if (
const result = await handler(_data, options); 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) { if (_data !== undefined && result !== undefined) {
const entry = { const entry = {
..._data.entry, ..._data.entry,
@ -419,15 +658,91 @@ export async function invokeEvent(
return _data.entry.data; return _data.entry.data;
} }
export function removeEventListener({ name, handler }: EventListener) { export function removeEventListener(listener: EventListener) {
const { name, handler } = listener;
validateEventName(name); validateEventName(name);
if (handler) { if (name === 'change') {
registry.eventHandlers[name] = registry.eventHandlers[name].filter( const collection = listener.collection;
item => item.handler !== handler, const file = listener.file;
); const field = listener.field;
} else {
registry.eventHandlers[name] = []; 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_DISCARD,
DRAFT_LOCAL_BACKUP_DELETE, DRAFT_LOCAL_BACKUP_DELETE,
DRAFT_LOCAL_BACKUP_RETRIEVED, DRAFT_LOCAL_BACKUP_RETRIEVED,
DRAFT_UPDATE,
DRAFT_VALIDATION_ERRORS, DRAFT_VALIDATION_ERRORS,
ENTRY_DELETE_SUCCESS, ENTRY_DELETE_SUCCESS,
ENTRY_PERSIST_FAILURE, ENTRY_PERSIST_FAILURE,
@ -147,6 +148,39 @@ function entryDraftReducer(
return newState; 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: { case DRAFT_CHANGE_FIELD: {
let newState = { ...state }; let newState = { ...state };
if (!newState.entry) { if (!newState.entry) {

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ const TextControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
() => (duplicate ? rawValue : internalRawValue), () => (duplicate ? rawValue : internalRawValue),
[internalRawValue, duplicate, rawValue], [internalRawValue, duplicate, rawValue],
); );
const debouncedInternalValue = useDebounce(internalValue, 200); const debouncedInternalValue = useDebounce(internalValue, 250);
const ref = useRef<HTMLInputElement | null>(null); 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 ```javascript
CMS.registerEventListener({ 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 ## 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. 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> </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 ##### 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. 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> | | 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) | | condition | FilterRule<br />\| List of FilterRules | | _Optional_. See [Field Conditions](#field-conditions) |
## Field Conditions ## Example Widget
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
<CodeTabs> <CodeTabs>
```yaml ```yaml
@ -78,3 +65,179 @@ pattern: ['.{12,}', 'Must have at least 12 characters'],
``` ```
</CodeTabs> </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>