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.
|
||||
- hidden removed as widget parameter
|
||||
- hidden removed as widget parameter
|
||||
- events
|
@ -1,5 +1,6 @@
|
||||
import { currentBackend } from '../backend';
|
||||
import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants';
|
||||
import { invokeEvent } from '../lib/registry';
|
||||
import { addSnackbar } from '../store/slices/snackbars';
|
||||
|
||||
import type { AnyAction } from 'redux';
|
||||
@ -35,6 +36,8 @@ export function doneAuthenticating() {
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
invokeEvent({ name: 'logout' });
|
||||
|
||||
return {
|
||||
type: LOGOUT,
|
||||
} as const;
|
||||
|
@ -4,6 +4,7 @@ import { currentBackend } from '../backend';
|
||||
import {
|
||||
ADD_DRAFT_ENTRY_MEDIA_FILE,
|
||||
CHANGE_VIEW_STYLE,
|
||||
DRAFT_UPDATE,
|
||||
DRAFT_CHANGE_FIELD,
|
||||
DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
|
||||
DRAFT_CREATE_EMPTY,
|
||||
@ -458,6 +459,13 @@ export function discardDraft() {
|
||||
return { type: DRAFT_DISCARD } as const;
|
||||
}
|
||||
|
||||
export function updateDraft({ data }: { data: EntryData }) {
|
||||
return {
|
||||
type: DRAFT_UPDATE,
|
||||
payload: { data },
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function changeDraftField({
|
||||
path,
|
||||
field,
|
||||
@ -1125,6 +1133,7 @@ export type EntriesAction = ReturnType<
|
||||
| typeof createDraftFromEntry
|
||||
| typeof draftDuplicateEntry
|
||||
| typeof discardDraft
|
||||
| typeof updateDraft
|
||||
| typeof changeDraftField
|
||||
| typeof changeDraftFieldValidation
|
||||
| typeof localBackupRetrieved
|
||||
|
@ -859,7 +859,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
|
||||
usedSlugs,
|
||||
status,
|
||||
}: PersistArgs) {
|
||||
const modifiedData = await this.invokePreSaveEvent(draft.entry);
|
||||
const modifiedData = await this.invokePreSaveEvent(draft.entry, collection);
|
||||
const entryDraft = modifiedData
|
||||
? {
|
||||
...draft,
|
||||
@ -950,7 +950,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
|
||||
opts,
|
||||
);
|
||||
|
||||
await this.invokePostSaveEvent(entryDraft.entry);
|
||||
await this.invokePostSaveEvent(entryDraft.entry, collection);
|
||||
|
||||
return slug;
|
||||
}
|
||||
@ -960,14 +960,14 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
|
||||
return { entry, author: { login, name } };
|
||||
}
|
||||
|
||||
async invokePreSaveEvent(entry: Entry): Promise<EntryData> {
|
||||
async invokePreSaveEvent(entry: Entry, collection: Collection): Promise<EntryData> {
|
||||
const eventData = await this.getEventData(entry);
|
||||
return await invokeEvent('preSave', eventData);
|
||||
return await invokeEvent({ name: 'preSave', collection: collection.name, data: eventData });
|
||||
}
|
||||
|
||||
async invokePostSaveEvent(entry: Entry): Promise<void> {
|
||||
async invokePostSaveEvent(entry: Entry, collection: Collection): Promise<void> {
|
||||
const eventData = await this.getEventData(entry);
|
||||
await invokeEvent('postSave', eventData);
|
||||
await invokeEvent({ name: 'postSave', collection: collection.name, data: eventData });
|
||||
}
|
||||
|
||||
async persistMedia(config: Config, file: AssetProxy) {
|
||||
|
@ -173,8 +173,6 @@ export default class Gitea implements BackendClass {
|
||||
throw new Error('Your Gitea user account does not have access to this repo.');
|
||||
}
|
||||
|
||||
console.log(user);
|
||||
|
||||
// Authorized user
|
||||
return {
|
||||
name: user.full_name,
|
||||
|
@ -188,9 +188,12 @@ const App = ({
|
||||
const [prevUser, setPrevUser] = useState(user);
|
||||
useEffect(() => {
|
||||
if (!prevUser && user) {
|
||||
invokeEvent('login', {
|
||||
login: user.login,
|
||||
name: user.name ?? '',
|
||||
invokeEvent({
|
||||
name: 'login',
|
||||
data: {
|
||||
login: user.login,
|
||||
name: user.name ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
setPrevUser(user);
|
||||
@ -243,7 +246,7 @@ const App = ({
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
invokeEvent('mounted');
|
||||
invokeEvent({ name: 'mounted' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
} from '@staticcms/core/actions/entries';
|
||||
import { loadMedia } from '@staticcms/core/actions/mediaLibrary';
|
||||
import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll';
|
||||
import useEntryCallback from '@staticcms/core/lib/hooks/useEntryCallback';
|
||||
import { selectFields } from '@staticcms/core/lib/util/collection.util';
|
||||
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
|
||||
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
||||
@ -210,6 +211,15 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
|
||||
};
|
||||
}, [collection, createBackup, entryDraft.entry, hasChanged]);
|
||||
|
||||
useEntryCallback({
|
||||
hasChanged,
|
||||
collection,
|
||||
slug,
|
||||
callback: () => {
|
||||
setVersion(version => version + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
|
||||
const [prevSlug, setPrevSlug] = useState<string | undefined | null>(null);
|
||||
useEffect(() => {
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
removeInsertedMedia as removeInsertedMediaAction,
|
||||
} from '@staticcms/core/actions/mediaLibrary';
|
||||
import { query as queryAction } from '@staticcms/core/actions/search';
|
||||
import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback';
|
||||
import useMemoCompare from '@staticcms/core/lib/hooks/useMemoCompare';
|
||||
import useUUID from '@staticcms/core/lib/hooks/useUUID';
|
||||
import { isFieldDuplicate, isFieldHidden } from '@staticcms/core/lib/i18n';
|
||||
@ -134,15 +135,18 @@ const EditorControl = ({
|
||||
]);
|
||||
|
||||
const handleChangeDraftField = useCallback(
|
||||
(value: ValueOrNestedValue) => {
|
||||
async (value: ValueOrNestedValue) => {
|
||||
setDirty(
|
||||
oldDirty => oldDirty || !isEmpty(widget.getValidValue(value, field as UnknownField)),
|
||||
);
|
||||
|
||||
changeDraftField({ path, field, value, i18n, isMeta });
|
||||
},
|
||||
[changeDraftField, field, i18n, isMeta, path, widget],
|
||||
);
|
||||
|
||||
const handleDebouncedChangeDraftField = useDebouncedCallback(handleChangeDraftField, 250);
|
||||
|
||||
const config = useMemo(() => configState.config, [configState.config]);
|
||||
|
||||
const finalValue = useMemoCompare(value, isEqual);
|
||||
@ -155,21 +159,23 @@ const EditorControl = ({
|
||||
|
||||
if ('default' in field && isNotNullish(!field.default)) {
|
||||
if (widget.getDefaultValue) {
|
||||
handleChangeDraftField(
|
||||
handleDebouncedChangeDraftField(
|
||||
widget.getDefaultValue(field.default, field as unknown as UnknownField),
|
||||
);
|
||||
} else {
|
||||
handleChangeDraftField(field.default);
|
||||
handleDebouncedChangeDraftField(field.default);
|
||||
}
|
||||
setVersion(version => version + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.getDefaultValue) {
|
||||
handleChangeDraftField(widget.getDefaultValue(null, field as unknown as UnknownField));
|
||||
handleDebouncedChangeDraftField(
|
||||
widget.getDefaultValue(null, field as unknown as UnknownField),
|
||||
);
|
||||
setVersion(version => version + 1);
|
||||
}
|
||||
}, [field, finalValue, handleChangeDraftField, widget]);
|
||||
}, [field, finalValue, handleDebouncedChangeDraftField, widget]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!collection || !entry || !config || field.widget === 'hidden') {
|
||||
@ -192,7 +198,7 @@ const EditorControl = ({
|
||||
label: getFieldLabel(field, t),
|
||||
locale,
|
||||
mediaPaths,
|
||||
onChange: handleChangeDraftField,
|
||||
onChange: handleDebouncedChangeDraftField,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
path,
|
||||
@ -225,7 +231,7 @@ const EditorControl = ({
|
||||
t,
|
||||
locale,
|
||||
mediaPaths,
|
||||
handleChangeDraftField,
|
||||
handleDebouncedChangeDraftField,
|
||||
openMediaLibrary,
|
||||
removeInsertedMedia,
|
||||
path,
|
||||
|
@ -47,6 +47,7 @@ export const GROUP_ENTRIES_FAILURE = 'GROUP_ENTRIES_FAILURE';
|
||||
export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY';
|
||||
export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
|
||||
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
|
||||
export const DRAFT_UPDATE = 'DRAFT_UPDATE';
|
||||
export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD';
|
||||
export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS';
|
||||
export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED';
|
||||
|
@ -47,7 +47,6 @@ import type {
|
||||
I18N_STRUCTURE_MULTIPLE_FOLDERS,
|
||||
I18N_STRUCTURE_SINGLE_FILE,
|
||||
} from './lib/i18n';
|
||||
import type { AllowedEvent } from './lib/registry';
|
||||
import type Cursor from './lib/util/Cursor';
|
||||
import type AssetProxy from './valueObjects/AssetProxy';
|
||||
|
||||
@ -922,45 +921,60 @@ export interface EventData {
|
||||
author: AuthorData;
|
||||
}
|
||||
|
||||
export type PreSaveEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
|
||||
data: EventData,
|
||||
options: O,
|
||||
) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
|
||||
|
||||
export type PostSaveEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
|
||||
data: EventData,
|
||||
options: O,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type MountedEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
|
||||
options: O,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type LoginEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
|
||||
data: AuthorData,
|
||||
options: O,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type LogoutEventHandler<O extends Record<string, unknown> = Record<string, unknown>> = (
|
||||
options: O,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type EventHandlers<O extends Record<string, unknown> = Record<string, unknown>> = {
|
||||
preSave: PreSaveEventHandler<O>;
|
||||
postSave: PostSaveEventHandler<O>;
|
||||
mounted: MountedEventHandler<O>;
|
||||
login: LoginEventHandler<O>;
|
||||
logout: LogoutEventHandler<O>;
|
||||
};
|
||||
|
||||
export interface EventListener<
|
||||
E extends AllowedEvent = 'mounted',
|
||||
O extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
name: E;
|
||||
handler: EventHandlers<O>[E];
|
||||
export interface PreSaveEventListener {
|
||||
name: 'preSave';
|
||||
collection: string;
|
||||
file?: string;
|
||||
handler: (event: {
|
||||
data: EventData;
|
||||
collection: string;
|
||||
}) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
|
||||
}
|
||||
|
||||
export interface PostSaveEventListener {
|
||||
name: 'postSave';
|
||||
collection: string;
|
||||
file?: string;
|
||||
handler: (event: { data: EventData; collection: string }) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface MountedEventListener {
|
||||
name: 'mounted';
|
||||
handler: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LoginEventListener {
|
||||
name: 'login';
|
||||
handler: (event: {
|
||||
author: AuthorData;
|
||||
}) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
|
||||
}
|
||||
|
||||
export interface LogoutEventListener {
|
||||
name: 'logout';
|
||||
handler: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ChangeEventListener {
|
||||
name: 'change';
|
||||
collection: string;
|
||||
file?: string;
|
||||
field: string;
|
||||
handler: (event: {
|
||||
data: EntryData;
|
||||
collection: string;
|
||||
field: string;
|
||||
}) => EntryData | undefined | null | void | Promise<EntryData | undefined | null | void>;
|
||||
}
|
||||
|
||||
export type EventListener =
|
||||
| PreSaveEventListener
|
||||
| PostSaveEventListener
|
||||
| ChangeEventListener
|
||||
| LoginEventListener
|
||||
| LogoutEventListener
|
||||
| MountedEventListener;
|
||||
|
||||
export interface AdditionalLinkOptions {
|
||||
icon?: string;
|
||||
}
|
||||
|
@ -1,25 +1,39 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const useDebouncedCallback = <T extends (...args: any) => any>(func: T, wait: number) => {
|
||||
// Use a ref to store the timeout between renders
|
||||
// and prevent changes to it from causing re-renders
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
export default function useDebouncedCallback<A extends any[], T = void>(
|
||||
callback: (...args: A) => T,
|
||||
wait: number,
|
||||
) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = useRef<A>();
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
function cleanup() {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
}
|
||||
|
||||
// make sure our timeout gets cleared if
|
||||
// our consuming component gets unmounted
|
||||
useEffect(() => cleanup, []);
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const later = () => {
|
||||
clearTimeout(timeout.current);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
func(...args);
|
||||
};
|
||||
function debouncedCallback(...args: A) {
|
||||
// capture latest args
|
||||
argsRef.current = args;
|
||||
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(later, wait);
|
||||
// clear debounce timer
|
||||
cleanup();
|
||||
|
||||
// start waiting again
|
||||
timeout.current = setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
}, wait);
|
||||
},
|
||||
[func, wait],
|
||||
[callback, wait],
|
||||
);
|
||||
};
|
||||
|
||||
export default useDebouncedCallback;
|
||||
}
|
||||
|
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 cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import type {
|
||||
AdditionalLink,
|
||||
@ -7,6 +8,7 @@ import type {
|
||||
BackendInitializer,
|
||||
BackendInitializerOptions,
|
||||
BaseField,
|
||||
ChangeEventListener,
|
||||
Collection,
|
||||
Config,
|
||||
CustomIcon,
|
||||
@ -16,12 +18,12 @@ import type {
|
||||
EventListener,
|
||||
FieldPreviewComponent,
|
||||
LocalePhrasesRoot,
|
||||
LoginEventHandler,
|
||||
LogoutEventHandler,
|
||||
MountedEventHandler,
|
||||
LoginEventListener,
|
||||
LogoutEventListener,
|
||||
MountedEventListener,
|
||||
ObjectValue,
|
||||
PostSaveEventHandler,
|
||||
PreSaveEventHandler,
|
||||
PostSaveEventListener,
|
||||
PreSaveEventListener,
|
||||
PreviewStyle,
|
||||
PreviewStyleOptions,
|
||||
ShortcodeConfig,
|
||||
@ -34,19 +36,49 @@ import type {
|
||||
WidgetValueSerializer,
|
||||
} from '../interface';
|
||||
|
||||
export const allowedEvents = ['mounted', 'login', 'logout', 'preSave', 'postSave'] as const;
|
||||
export const allowedEvents = [
|
||||
'mounted',
|
||||
'login',
|
||||
'logout',
|
||||
'preSave',
|
||||
'postSave',
|
||||
'change',
|
||||
] as const;
|
||||
export type AllowedEvent = (typeof allowedEvents)[number];
|
||||
|
||||
type EventHandlerRegistry = {
|
||||
preSave: { handler: PreSaveEventHandler; options: Record<string, unknown> }[];
|
||||
postSave: { handler: PostSaveEventHandler; options: Record<string, unknown> }[];
|
||||
mounted: { handler: MountedEventHandler; options: Record<string, unknown> }[];
|
||||
login: { handler: LoginEventHandler; options: Record<string, unknown> }[];
|
||||
logout: { handler: LogoutEventHandler; options: Record<string, unknown> }[];
|
||||
preSave: Record<
|
||||
string,
|
||||
PreSaveEventListener['handler'][] | Record<string, PreSaveEventListener['handler'][]>
|
||||
>;
|
||||
postSave: Record<
|
||||
string,
|
||||
PostSaveEventListener['handler'][] | Record<string, PostSaveEventListener['handler'][]>
|
||||
>;
|
||||
mounted: MountedEventListener['handler'][];
|
||||
login: LoginEventListener['handler'][];
|
||||
logout: LogoutEventListener['handler'][];
|
||||
change: Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
ChangeEventListener['handler'][] | Record<string, ChangeEventListener['handler'][]>
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
const eventHandlers = allowedEvents.reduce((acc, e) => {
|
||||
acc[e] = [];
|
||||
switch (e) {
|
||||
case 'preSave':
|
||||
case 'postSave':
|
||||
case 'change':
|
||||
acc[e] = {};
|
||||
break;
|
||||
default:
|
||||
acc[e] = [];
|
||||
break;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as EventHandlerRegistry);
|
||||
|
||||
@ -340,41 +372,158 @@ function validateEventName(name: AllowedEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getEventListeners(name: AllowedEvent) {
|
||||
export function getEventListeners(options: {
|
||||
name: AllowedEvent;
|
||||
collection?: string;
|
||||
field?: string;
|
||||
}) {
|
||||
const { name } = options;
|
||||
|
||||
validateEventName(name);
|
||||
|
||||
if (name === 'change') {
|
||||
if (!options.field || !options.collection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (registry.eventHandlers[name][options.collection] ?? {})[options.field] ?? [];
|
||||
}
|
||||
|
||||
if (name === 'preSave' || name === 'postSave') {
|
||||
if (!options.collection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return registry.eventHandlers[name][options.collection] ?? [];
|
||||
}
|
||||
|
||||
return [...registry.eventHandlers[name]];
|
||||
}
|
||||
|
||||
export function registerEventListener<
|
||||
E extends AllowedEvent,
|
||||
O extends Record<string, unknown> = Record<string, unknown>,
|
||||
>({ name, handler }: EventListener<E, O>, options?: O) {
|
||||
export function registerEventListener(listener: EventListener) {
|
||||
const { name, handler } = listener;
|
||||
validateEventName(name);
|
||||
registry.eventHandlers[name].push({
|
||||
handler: handler as MountedEventHandler &
|
||||
LoginEventHandler &
|
||||
PreSaveEventHandler &
|
||||
PostSaveEventHandler,
|
||||
options: options ?? {},
|
||||
});
|
||||
|
||||
if (name === 'change') {
|
||||
const collection = listener.collection;
|
||||
const file = listener.file;
|
||||
const field = listener.field;
|
||||
|
||||
if (!(collection in registry.eventHandlers[name])) {
|
||||
registry.eventHandlers[name][collection] = {};
|
||||
}
|
||||
|
||||
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(name: 'logout'): Promise<void>;
|
||||
export async function invokeEvent(name: 'preSave', data: EventData): Promise<EntryData>;
|
||||
export async function invokeEvent(name: 'postSave', data: EventData): Promise<void>;
|
||||
export async function invokeEvent(name: 'mounted'): Promise<void>;
|
||||
export async function invokeEvent(
|
||||
name: AllowedEvent,
|
||||
data?: EventData | AuthorData,
|
||||
): Promise<void | EntryData> {
|
||||
export async function invokeEvent(event: { name: 'login'; data: AuthorData }): Promise<void>;
|
||||
export async function invokeEvent(event: { name: 'logout' }): Promise<void>;
|
||||
export async function invokeEvent(event: {
|
||||
name: 'preSave';
|
||||
data: EventData;
|
||||
collection: string;
|
||||
file?: string;
|
||||
}): Promise<EntryData>;
|
||||
export async function invokeEvent(event: {
|
||||
name: 'postSave';
|
||||
data: EventData;
|
||||
collection: string;
|
||||
file?: string;
|
||||
}): Promise<void>;
|
||||
export async function invokeEvent(event: { name: 'mounted' }): Promise<void>;
|
||||
export async function invokeEvent(event: {
|
||||
name: 'change';
|
||||
data: EntryData | undefined;
|
||||
collection: string;
|
||||
file?: string;
|
||||
field: string;
|
||||
}): Promise<EntryData>;
|
||||
export async function invokeEvent(event: {
|
||||
name: AllowedEvent;
|
||||
data?: EventData | EntryData | AuthorData;
|
||||
collection?: string;
|
||||
file?: string;
|
||||
field?: string;
|
||||
}): Promise<void | EntryData> {
|
||||
const { name, data, collection, field } = event;
|
||||
|
||||
validateEventName(name);
|
||||
|
||||
if (name === 'mounted' || name === 'logout') {
|
||||
console.info(`[StaticCMS] Firing ${name} event`);
|
||||
const handlers = registry.eventHandlers[name];
|
||||
for (const { handler, options } of handlers) {
|
||||
handler(options);
|
||||
for (const handler of handlers) {
|
||||
handler();
|
||||
}
|
||||
|
||||
return;
|
||||
@ -383,30 +532,120 @@ export async function invokeEvent(
|
||||
if (name === 'login') {
|
||||
console.info('[StaticCMS] Firing login event', data);
|
||||
const handlers = registry.eventHandlers[name];
|
||||
for (const { handler, options } of handlers) {
|
||||
handler(data as AuthorData, options);
|
||||
for (const handler of handlers) {
|
||||
handler({ author: data as AuthorData });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'postSave') {
|
||||
console.info(`[StaticCMS] Firing post save event`, data);
|
||||
const handlers = registry.eventHandlers[name];
|
||||
for (const { handler, options } of handlers) {
|
||||
handler(data as EventData, options);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[StaticCMS] Firing post save event for${
|
||||
event.file ? ` "${event.file}" file in` : ''
|
||||
} "${collection}" collection`,
|
||||
data,
|
||||
);
|
||||
const handlers = registry.eventHandlers[name][collection];
|
||||
|
||||
let finalHandlers: PostSaveEventListener['handler'][];
|
||||
if (event.file && !Array.isArray(handlers)) {
|
||||
finalHandlers =
|
||||
(
|
||||
registry.eventHandlers[name][collection] as Record<
|
||||
string,
|
||||
PostSaveEventListener['handler'][]
|
||||
>
|
||||
)[event.file] ?? [];
|
||||
} else if (Array.isArray(handlers)) {
|
||||
finalHandlers = handlers ?? [];
|
||||
} else {
|
||||
finalHandlers = [];
|
||||
}
|
||||
|
||||
for (const handler of finalHandlers) {
|
||||
handler({ data: data as EventData, collection });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handlers = registry.eventHandlers[name];
|
||||
if (name === 'change') {
|
||||
if (!collection || !field || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(`[StaticCMS] Firing pre save event`, data);
|
||||
let _data = cloneDeep(data as EntryData);
|
||||
console.info(
|
||||
`[StaticCMS] Firing change event for field "${field}" for${
|
||||
event.file ? ` "${event.file}" file in` : ''
|
||||
} "${collection}" collection, new value:`,
|
||||
data,
|
||||
);
|
||||
const collectionHandlers = registry.eventHandlers[name][collection] ?? {};
|
||||
|
||||
let _data = { ...(data as EventData) };
|
||||
for (const { handler, options } of handlers) {
|
||||
const result = await handler(_data, options);
|
||||
let finalHandlers: Record<string, ChangeEventListener['handler'][]>;
|
||||
if (
|
||||
event.file &&
|
||||
event.file in collectionHandlers &&
|
||||
!Array.isArray(collectionHandlers[event.file])
|
||||
) {
|
||||
finalHandlers =
|
||||
(collectionHandlers as Record<string, Record<string, ChangeEventListener['handler'][]>>)[
|
||||
event.file
|
||||
] ?? {};
|
||||
} else if (Array.isArray(collectionHandlers[field])) {
|
||||
finalHandlers = collectionHandlers as Record<string, ChangeEventListener['handler'][]>;
|
||||
} else {
|
||||
finalHandlers = {};
|
||||
}
|
||||
|
||||
const handlers = finalHandlers[field] ?? [];
|
||||
|
||||
for (const handler of handlers) {
|
||||
const result = await handler({ data: _data, collection, field });
|
||||
if (_data !== undefined && result) {
|
||||
_data = result;
|
||||
}
|
||||
}
|
||||
|
||||
return _data;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _data = cloneDeep(data as EventData);
|
||||
console.info(
|
||||
`[StaticCMS] Firing pre save event for${
|
||||
event.file ? ` "${event.file}" file in` : ''
|
||||
} "${collection}" collection`,
|
||||
data,
|
||||
);
|
||||
const handlers = registry.eventHandlers[name][collection] ?? [];
|
||||
|
||||
let finalHandlers: PreSaveEventListener['handler'][];
|
||||
if (event.file && !Array.isArray(handlers)) {
|
||||
finalHandlers =
|
||||
(
|
||||
registry.eventHandlers[name][collection] as Record<
|
||||
string,
|
||||
PreSaveEventListener['handler'][]
|
||||
>
|
||||
)[event.file] ?? [];
|
||||
} else if (Array.isArray(handlers)) {
|
||||
finalHandlers = handlers ?? [];
|
||||
} else {
|
||||
finalHandlers = [];
|
||||
}
|
||||
|
||||
for (const handler of finalHandlers) {
|
||||
const result = await handler({ data: _data, collection });
|
||||
if (_data !== undefined && result !== undefined) {
|
||||
const entry = {
|
||||
..._data.entry,
|
||||
@ -419,15 +658,91 @@ export async function invokeEvent(
|
||||
return _data.entry.data;
|
||||
}
|
||||
|
||||
export function removeEventListener({ name, handler }: EventListener) {
|
||||
export function removeEventListener(listener: EventListener) {
|
||||
const { name, handler } = listener;
|
||||
|
||||
validateEventName(name);
|
||||
if (handler) {
|
||||
registry.eventHandlers[name] = registry.eventHandlers[name].filter(
|
||||
item => item.handler !== handler,
|
||||
if (name === 'change') {
|
||||
const collection = listener.collection;
|
||||
const file = listener.file;
|
||||
const field = listener.field;
|
||||
|
||||
if (!(collection in registry.eventHandlers[name])) {
|
||||
registry.eventHandlers[name][collection] = {};
|
||||
}
|
||||
|
||||
if (file) {
|
||||
if (!(file in registry.eventHandlers[name][collection])) {
|
||||
registry.eventHandlers[name][collection][file] = {};
|
||||
}
|
||||
|
||||
if (!(field in registry.eventHandlers[name][collection][file])) {
|
||||
(
|
||||
registry.eventHandlers[name][collection][file] as Record<
|
||||
string,
|
||||
ChangeEventListener['handler'][]
|
||||
>
|
||||
)[field] = [];
|
||||
}
|
||||
|
||||
(
|
||||
registry.eventHandlers[name][collection][file] as Record<
|
||||
string,
|
||||
ChangeEventListener['handler'][]
|
||||
>
|
||||
)[field].filter(item => item !== handler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(field in registry.eventHandlers[name][collection])) {
|
||||
registry.eventHandlers[name][collection][field] = [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(registry.eventHandlers[name][collection][field])) {
|
||||
return;
|
||||
}
|
||||
|
||||
(registry.eventHandlers[name][collection][field] as ChangeEventListener['handler'][]).filter(
|
||||
item => item !== handler,
|
||||
);
|
||||
} else {
|
||||
registry.eventHandlers[name] = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'preSave' || name === 'postSave') {
|
||||
const collection = listener.collection;
|
||||
const file = listener.file;
|
||||
|
||||
if (file) {
|
||||
if (!(collection in registry.eventHandlers[name])) {
|
||||
registry.eventHandlers[name][collection] = {};
|
||||
}
|
||||
|
||||
if (!(file in registry.eventHandlers[name][collection])) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(registry.eventHandlers[name][collection] as Record<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_LOCAL_BACKUP_DELETE,
|
||||
DRAFT_LOCAL_BACKUP_RETRIEVED,
|
||||
DRAFT_UPDATE,
|
||||
DRAFT_VALIDATION_ERRORS,
|
||||
ENTRY_DELETE_SUCCESS,
|
||||
ENTRY_PERSIST_FAILURE,
|
||||
@ -147,6 +148,39 @@ function entryDraftReducer(
|
||||
return newState;
|
||||
}
|
||||
|
||||
case DRAFT_UPDATE: {
|
||||
let newState = { ...state };
|
||||
if (!newState.entry) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { data } = action.payload;
|
||||
|
||||
newState = {
|
||||
...newState,
|
||||
entry: {
|
||||
...newState.entry,
|
||||
data,
|
||||
},
|
||||
};
|
||||
|
||||
let hasChanged =
|
||||
!isEqual(newState.entry?.meta, newState.original?.meta) ||
|
||||
!isEqual(newState.entry?.data, newState.original?.data);
|
||||
|
||||
const i18nData = newState.entry?.i18n ?? {};
|
||||
for (const locale in i18nData) {
|
||||
hasChanged =
|
||||
hasChanged ||
|
||||
!isEqual(newState.entry?.i18n?.[locale]?.data, newState.original?.i18n?.[locale]?.data);
|
||||
}
|
||||
|
||||
return {
|
||||
...newState,
|
||||
hasChanged: !newState.original || hasChanged,
|
||||
};
|
||||
}
|
||||
|
||||
case DRAFT_CHANGE_FIELD: {
|
||||
let newState = { ...state };
|
||||
if (!newState.entry) {
|
||||
|
@ -27,7 +27,7 @@ const DelimitedListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListFiel
|
||||
() => (controlled || duplicate ? rawValue : internalRawValue),
|
||||
[controlled, duplicate, rawValue, internalRawValue],
|
||||
);
|
||||
const debouncedInternalValue = useDebounce(internalValue, 200);
|
||||
const debouncedInternalValue = useDebounce(internalValue, 250);
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
|
@ -60,7 +60,8 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
|
||||
setPrevValue(parsedValue);
|
||||
setValue(parsedValue);
|
||||
}
|
||||
}, [prevValue, setValue, value]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div key="markdown-preview">
|
||||
|
@ -24,7 +24,7 @@ const StringControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
|
||||
() => (controlled || duplicate ? rawValue : internalRawValue),
|
||||
[controlled, duplicate, rawValue, internalRawValue],
|
||||
);
|
||||
const debouncedInternalValue = useDebounce(internalValue, 200);
|
||||
const debouncedInternalValue = useDebounce(internalValue, 250);
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
|
@ -24,7 +24,7 @@ const TextControl: FC<WidgetControlProps<string, StringOrTextField>> = ({
|
||||
() => (duplicate ? rawValue : internalRawValue),
|
||||
[internalRawValue, duplicate, rawValue],
|
||||
);
|
||||
const debouncedInternalValue = useDebounce(internalValue, 200);
|
||||
const debouncedInternalValue = useDebounce(internalValue, 250);
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
|
@ -92,7 +92,11 @@ CMS.registerEventListener({
|
||||
});
|
||||
```
|
||||
|
||||
Supported events are `mounted`, `login`, `logout`, `preSave` and `postSave`. The `preSave` hook can be used to modify the entry data like so:
|
||||
Supported events are `mounted`, `login`, `logout`, `change`, `preSave` and `postSave`.
|
||||
|
||||
### PreSave
|
||||
|
||||
The `preSave` event handler can be used to modify the entry data like so:
|
||||
|
||||
```javascript
|
||||
CMS.registerEventListener({
|
||||
@ -109,6 +113,24 @@ CMS.registerEventListener({
|
||||
});
|
||||
```
|
||||
|
||||
### Change
|
||||
|
||||
The `change` event handler must provide a field name, and can be used to modify the entry data like so:
|
||||
|
||||
```javascript
|
||||
CMS.registerEventListener(
|
||||
{
|
||||
name: 'change',
|
||||
handler: ({ entry }, { field }) => {
|
||||
return 'newFieldValue';
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'path.to.my.field',
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## i18n Support
|
||||
|
||||
Static CMS can provide a side by side interface for authoring content in multiple languages. Configuring Static CMS for i18n support requires top level configuration, collection level configuration and field level configuration.
|
||||
|
@ -230,6 +230,79 @@ collections: [
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
##### Filtered by Nested Field
|
||||
|
||||
The example below creates a collection based on a nested field's value.
|
||||
|
||||
<CodeTabs>
|
||||
```yaml
|
||||
collections:
|
||||
- name: 'nested-field-filtered-collection'
|
||||
label: 'Nested Field Filtered Collection'
|
||||
folder: '_nested_field'
|
||||
create: true
|
||||
filter:
|
||||
field: nested.object.field
|
||||
value: yes
|
||||
fields:
|
||||
- name: nested
|
||||
label: Nested
|
||||
widget: object
|
||||
fields:
|
||||
- name: object
|
||||
label: Object
|
||||
widget: object
|
||||
fields:
|
||||
- name: field
|
||||
label: Field
|
||||
widget: select
|
||||
options:
|
||||
- yes
|
||||
- no
|
||||
```
|
||||
|
||||
```js
|
||||
collections: [
|
||||
{
|
||||
name: "nested-field-filtered-collection",
|
||||
label: "Nested Field Filtered Collection",
|
||||
folder: "_nested_field",
|
||||
create: true,
|
||||
filter: {
|
||||
field: "nested.object.field",
|
||||
value: "yes"
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "nested",
|
||||
label: "Nested",
|
||||
widget: "object",
|
||||
fields: [
|
||||
{
|
||||
name: "object",
|
||||
label: "Object",
|
||||
widget: "object",
|
||||
fields: [
|
||||
{
|
||||
name: "field",
|
||||
label: "Field",
|
||||
widget: "select",
|
||||
options: [
|
||||
"yes",
|
||||
"no"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
```
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
##### Filtered by Tags
|
||||
|
||||
The example below creates two collections in the same folder, filtered by the `tags` field. The first collection includes posts with the `news` or `article` tags, and the second, with the `blog` tag.
|
||||
|
@ -47,20 +47,7 @@ The following options are available on all fields:
|
||||
| i18n | boolean<br />\| 'translate'<br />\| 'duplicate'<br />\| 'none' | | _Optional_. <BetaImage /><ul><li>`translate` - Allows translation of the field</li><li>`duplicate` - Duplicates the value from the default locale</li><li>`true` - Accept parent values as default</li><li>`none` or `false` - Exclude field from translations</li></ul> |
|
||||
| condition | FilterRule<br />\| List of FilterRules | | _Optional_. See [Field Conditions](#field-conditions) |
|
||||
|
||||
## Field Conditions
|
||||
|
||||
The fields can be shown conditionally based on the values of the other fields.
|
||||
|
||||
The `condition` option can take a single filter rule or a list of filter rules.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| field | string | | The name of one of the field. |
|
||||
| value | string<br />\| list of strings | | _Optional_. The desired value or values to match. Required if no `pattern` provided. Ignored if `pattern` is provided |
|
||||
| pattern | regular expression | | _Optional_. A regex pattern to match against the field's value |
|
||||
| matchAll | boolean | `false` | _Optional_. _Ignored if value is not a list of strings_<br /><ul><li>`true` - The field's values must include or match all of the filter rule's values</li><li>`false` - The field's value must include or match only one of the filter rule's values</li></ul> |
|
||||
|
||||
## Example
|
||||
## Example Widget
|
||||
|
||||
<CodeTabs>
|
||||
```yaml
|
||||
@ -78,3 +65,179 @@ pattern: ['.{12,}', 'Must have at least 12 characters'],
|
||||
```
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Field Conditions
|
||||
|
||||
The fields can be shown conditionally based on the values of the other fields.
|
||||
|
||||
The `condition` option can take a single filter rule or a list of filter rules.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| field | string | | The name of one of the field. |
|
||||
| value | string<br />\| list of strings | | _Optional_. The desired value or values to match. Required if no `pattern` provided. Ignored if `pattern` is provided |
|
||||
| pattern | regular expression | | _Optional_. A regex pattern to match against the field's value |
|
||||
| matchAll | boolean | `false` | _Optional_. _Ignored if value is not a list of strings_<br /><ul><li>`true` - The field's values must include or match all of the filter rule's values</li><li>`false` - The field's value must include or match only one of the filter rule's values</li></ul> |
|
||||
|
||||
### Example
|
||||
|
||||
The example below creates a collection based on a nested field's value.
|
||||
|
||||
<CodeTabs>
|
||||
```yaml
|
||||
collections:
|
||||
- name: 'nested-field-filtered-collection'
|
||||
label: 'Nested Field Filtered Collection'
|
||||
folder: '_field_condition'
|
||||
create: true
|
||||
fields:
|
||||
- name: type
|
||||
label: Type
|
||||
widget: select
|
||||
options:
|
||||
- value: type1
|
||||
label: Type 1
|
||||
- value: type2
|
||||
label: Type 2
|
||||
- name: value1
|
||||
label: Value 1
|
||||
widget: string
|
||||
condition:
|
||||
field: type
|
||||
value: type1
|
||||
- name: value2
|
||||
label: Value 2
|
||||
widget: text
|
||||
condition:
|
||||
field: type
|
||||
value: type2
|
||||
```
|
||||
|
||||
```js
|
||||
collections: [
|
||||
{
|
||||
name: "nested-field-filtered-collection",
|
||||
label: "Nested Field Filtered Collection",
|
||||
folder: "_field_condition",
|
||||
create: true,
|
||||
fields: [
|
||||
{
|
||||
name: "type",
|
||||
label: "Type",
|
||||
widget: "select",
|
||||
options: [
|
||||
{
|
||||
value: "type1",
|
||||
label: "Type 1"
|
||||
},
|
||||
{
|
||||
value: "type2",
|
||||
label: "Type 2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "value1",
|
||||
label: "Value 1",
|
||||
widget: "string",
|
||||
condition: {
|
||||
field: "type",
|
||||
value: "type1"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "value2",
|
||||
label: "Value 2",
|
||||
widget: "text",
|
||||
condition: {
|
||||
field: "type",
|
||||
value: "type2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
```
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
### Nested Field Example
|
||||
|
||||
The example below creates a collection based on a nested field's value.
|
||||
|
||||
<CodeTabs>
|
||||
```yaml
|
||||
collections:
|
||||
- name: 'nested-field-filtered-collection'
|
||||
label: 'Nested Field Filtered Collection'
|
||||
folder: '_nested_field_condition'
|
||||
create: true
|
||||
fields:
|
||||
- name: value
|
||||
label: Value 1
|
||||
widget: string
|
||||
condition:
|
||||
field: nested.object.field
|
||||
value: yes
|
||||
- name: nested
|
||||
label: Nested
|
||||
widget: object
|
||||
fields:
|
||||
- name: object
|
||||
label: Object
|
||||
widget: object
|
||||
fields:
|
||||
- name: field
|
||||
label: Field
|
||||
widget: select
|
||||
options:
|
||||
- yes
|
||||
- no
|
||||
```
|
||||
|
||||
```js
|
||||
collections: [
|
||||
{
|
||||
name: "nested-field-filtered-collection",
|
||||
label: "Nested Field Filtered Collection",
|
||||
folder: "_nested_field_condition",
|
||||
create: true,
|
||||
fields: [
|
||||
{
|
||||
name: "value",
|
||||
label: "Value 1",
|
||||
widget: "string",
|
||||
condition: {
|
||||
field: "nested.object.field",
|
||||
value: "yes"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "nested",
|
||||
label: "Nested",
|
||||
widget: "object",
|
||||
fields: [
|
||||
{
|
||||
name: "object",
|
||||
label: "Object",
|
||||
widget: "object",
|
||||
fields: [
|
||||
{
|
||||
name: "field",
|
||||
label: "Field",
|
||||
widget: "select",
|
||||
options: [
|
||||
"yes",
|
||||
"no"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
```
|
||||
|
||||
</CodeTabs>
|
||||
|
Loading…
x
Reference in New Issue
Block a user