Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/root-cms/core/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
`);
});
16 changes: 15 additions & 1 deletion packages/root-cms/core/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,19 @@ export function reference(field: Omit<ReferenceField, 'type'>): 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, 'type'>): UidField {
return {...field, type: 'uid'};
}

export type Field =
| StringField
| NumberField
Expand All @@ -231,7 +244,8 @@ export type Field =
| ArrayField
| OneOfField
| RichTextField
| ReferenceField;
| ReferenceField
| UidField;

/**
* Similar to {@link Field} but with a required `id`.
Expand Down
3 changes: 3 additions & 0 deletions packages/root-cms/ui/components/DocEditor/DocEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -364,6 +365,8 @@ DocEditor.Field = (props: FieldProps) => {
<SelectField {...props} />
) : field.type === 'string' ? (
<StringField {...props} />
) : field.type === 'uid' ? (
<UidField {...props} />
) : (
<div className="DocEditor__field__input__unknown">
Unknown field type: {field.type}
Expand Down
130 changes: 130 additions & 0 deletions packages/root-cms/ui/components/DocEditor/fields/UidField.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(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 (
<div>
<Group spacing="xs">
<TextInput
size="xs"
radius={0}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value);
}}
style={{flex: 1}}
placeholder={field.placeholder || 'Enter UID or generate one'}
/>
<Button
size="xs"
variant="light"
leftIcon={<IconRefresh size={14} />}
onClick={generateUid}
>
{field.buttonLabel || 'Generate UID'}
</Button>
</Group>
{duplicateWarning && (
<Alert
icon={<IconAlertTriangle size={16} />}
title="Duplicate UID Warning"
color="orange"
variant="light"
mt="xs"
>
{duplicateWarning}
</Alert>
)}
</div>
);
}

/**
* 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;
}