feat: change event (#842)
This commit is contained in:
parent
16819ed7be
commit
eff9a62c5b
@ -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
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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) {
|
||||||
|
@ -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,
|
||||||
|
@ -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' });
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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,
|
||||||
|
@ -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';
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
|
112
packages/core/src/lib/hooks/useEntryCallback.ts
Normal file
112
packages/core/src/lib/hooks/useEntryCallback.ts
Normal 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]);
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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) {
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user