diff --git a/packages/root-cms/core/schema.test.ts b/packages/root-cms/core/schema.test.ts index 1c5ce130..1290c156 100644 --- a/packages/root-cms/core/schema.test.ts +++ b/packages/root-cms/core/schema.test.ts @@ -416,3 +416,43 @@ test('define schema', () => { } `); }); + +test('uid field', () => { + expect( + schema.uid({ + id: 'testUid', + label: 'Test UID', + tag: 'element', + }) + ).toMatchInlineSnapshot(` + { + "id": "testUid", + "label": "Test UID", + "tag": "element", + "type": "uid", + } + `); +}); + +test('uid field with all options', () => { + expect( + schema.uid({ + id: 'pageUid', + label: 'Page Element UID', + help: 'Unique identifier for page elements', + tag: 'page-element', + buttonLabel: 'Generate ID', + default: 'default-uid-123', + }) + ).toMatchInlineSnapshot(` + { + "buttonLabel": "Generate ID", + "default": "default-uid-123", + "help": "Unique identifier for page elements", + "id": "pageUid", + "label": "Page Element UID", + "tag": "page-element", + "type": "uid", + } + `); +}); diff --git a/packages/root-cms/core/schema.ts b/packages/root-cms/core/schema.ts index bc6b5731..6d09238a 100644 --- a/packages/root-cms/core/schema.ts +++ b/packages/root-cms/core/schema.ts @@ -217,6 +217,19 @@ export function reference(field: Omit): ReferenceField { return {...field, type: 'reference'}; } +export type UidField = CommonFieldProps & { + type: 'uid'; + default?: string; + /** Optional tag/category to group UIDs by purpose. */ + tag?: string; + /** Button label for generating a new UID. Defaults to "Generate UID". */ + buttonLabel?: string; +}; + +export function uid(field: Omit): UidField { + return {...field, type: 'uid'}; +} + export type Field = | StringField | NumberField @@ -231,7 +244,8 @@ export type Field = | ArrayField | OneOfField | RichTextField - | ReferenceField; + | ReferenceField + | UidField; /** * Similar to {@link Field} but with a required `id`. diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 66ef13cb..816302ac 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -79,6 +79,7 @@ import {RichTextField} from './fields/RichTextField.js'; import {SelectField} from './fields/SelectField.js'; import {StringField} from './fields/StringField.js'; import {NumberField} from './fields/NumberField.js'; +import {UidField} from './fields/UidField.js'; import {testFieldEmpty} from '../../utils/test-field-empty.js'; interface DocEditorProps { @@ -364,6 +365,8 @@ DocEditor.Field = (props: FieldProps) => { ) : field.type === 'string' ? ( + ) : field.type === 'uid' ? ( + ) : (
Unknown field type: {field.type} diff --git a/packages/root-cms/ui/components/DocEditor/fields/UidField.tsx b/packages/root-cms/ui/components/DocEditor/fields/UidField.tsx new file mode 100644 index 00000000..78045854 --- /dev/null +++ b/packages/root-cms/ui/components/DocEditor/fields/UidField.tsx @@ -0,0 +1,130 @@ +import {Alert, Button, Group, TextInput} from '@mantine/core'; +import {IconAlertTriangle, IconRefresh} from '@tabler/icons-preact'; +import {ChangeEvent} from 'preact/compat'; +import {useEffect, useMemo, useState} from 'preact/hooks'; +import * as schema from '../../../../core/schema.js'; +import {FieldProps} from './FieldProps.js'; + +export function UidField(props: FieldProps) { + const field = props.field as schema.UidField; + const [value, setValue] = useState(''); + const [docData, setDocData] = useState(null); + + function onChange(newValue: string) { + setValue(newValue); + props.draft.updateKey(props.deepKey, newValue); + } + + function generateUid() { + const timestamp = Date.now(); + const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); + const tag = field.tag ? `${field.tag}-` : ''; + const newUid = `${tag}${timestamp}-${random}`; + onChange(newUid); + } + + useEffect(() => { + const unsubscribe = props.draft.subscribe( + props.deepKey, + (newValue: string) => { + setValue(newValue || ''); + } + ); + return unsubscribe; + }, []); + + // Subscribe to document changes for duplicate detection + useEffect(() => { + const unsubscribe = props.draft.onChange((data: any) => { + setDocData(data); + }); + return unsubscribe; + }, []); + + // Check for duplicate UIDs in the document + const duplicateWarning = useMemo(() => { + if (!value || !docData) return null; + + const duplicates = findDuplicateUids(docData, value, props.deepKey); + + if (duplicates.length > 0) { + return `Duplicate UID found in field(s): ${duplicates.join(', ')}`; + } + return null; + }, [value, docData, props.deepKey]); + + return ( +
+ + ) => { + onChange(e.currentTarget.value); + }} + style={{flex: 1}} + placeholder={field.placeholder || 'Enter UID or generate one'} + /> + + + {duplicateWarning && ( + } + title="Duplicate UID Warning" + color="orange" + variant="light" + mt="xs" + > + {duplicateWarning} + + )} +
+ ); +} + +/** + * Recursively searches for duplicate UIDs in the document data. + */ +function findDuplicateUids( + data: any, + targetUid: string, + excludeKey: string, + path = '' +): string[] { + const duplicates: string[] = []; + + if (!data || typeof data !== 'object') { + return duplicates; + } + + for (const [key, value] of Object.entries(data)) { + const currentPath = path ? `${path}.${key}` : key; + + if (currentPath === excludeKey) { + continue; // Skip the current field + } + + if (typeof value === 'string' && value === targetUid) { + duplicates.push(currentPath); + } else if (Array.isArray(value)) { + // Check array items + value.forEach((item, index) => { + const arrayPath = `${currentPath}[${index}]`; + duplicates.push(...findDuplicateUids(item, targetUid, excludeKey, arrayPath)); + }); + } else if (typeof value === 'object' && value !== null) { + // Recursively check nested objects + duplicates.push(...findDuplicateUids(value, targetUid, excludeKey, currentPath)); + } + } + + return duplicates; +} \ No newline at end of file