Skip to content
Merged
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
172 changes: 164 additions & 8 deletions apps/web/app/(builder)/lib/schema-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,158 @@ function cleanEmptyValues<T>(obj: T): T {
return obj;
}

const BASE_FIELD_KEYS = new Set([
'type',
'name',
'id',
'label',
'description',
'placeholder',
'required',
'disabled',
'hidden',
'readOnly',
'defaultValue',
'schema',
'validate',
'condition',
'style',
'component',
'input',
'autoComplete',
'meta',
]);

const TYPE_KEYS: Record<string, Set<string>> = {
text: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'pattern', 'trim', 'ui']),
email: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'pattern', 'trim', 'ui']),
password: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'criteria', 'ui']),
textarea: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'rows', 'autoResize', 'ui']),
number: new Set([...BASE_FIELD_KEYS, 'min', 'max', 'precision', 'ui']),
date: new Set([...BASE_FIELD_KEYS, 'minDate', 'maxDate', 'ui']),
datetime: new Set([...BASE_FIELD_KEYS, 'minDate', 'maxDate', 'ui']),
select: new Set([
...BASE_FIELD_KEYS,
'options',
'dependencies',
'hasMany',
'minSelected',
'maxSelected',
'ui',
]),
'checkbox-group': new Set([
...BASE_FIELD_KEYS,
'options',
'dependencies',
'minSelected',
'maxSelected',
'ui',
]),
checkbox: new Set([...BASE_FIELD_KEYS]),
switch: new Set([...BASE_FIELD_KEYS, 'ui']),
radio: new Set([...BASE_FIELD_KEYS, 'options', 'dependencies', 'ui']),
tags: new Set([...BASE_FIELD_KEYS, 'minTags', 'maxTags', 'maxTagLength', 'allowDuplicates', 'ui']),
upload: new Set([
...BASE_FIELD_KEYS,
'hasMany',
'minFiles',
'maxFiles',
'maxSize',
'ui',
]),
group: new Set([...BASE_FIELD_KEYS, 'fields', 'ui']),
array: new Set([...BASE_FIELD_KEYS, 'fields', 'minRows', 'maxRows', 'ui']),
row: new Set(['type', 'fields', 'ui']),
tabs: new Set(['type', 'tabs', 'ui']),
collapsible: new Set(['type', 'label', 'fields', 'collapsed', 'ui', 'style']),
};

const SELECT_OPTION_KEYS = new Set([
'label',
'value',
'description',
'icon',
'badge',
'disabled',
]);

const TAB_KEYS = new Set([
'name',
'label',
'fields',
'description',
'icon',
'disabled',
]);

function pickKeys<T extends Record<string, unknown>>(value: T, allowed: Set<string>): T {
const next: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (allowed.has(key)) {
next[key] = val;
}
}
return next as T;
}

function sanitizeSelectOptions(options: unknown): unknown {
if (!Array.isArray(options)) return options;

return options.map((option) => {
if (typeof option === 'string') return option;
if (option && typeof option === 'object' && !Array.isArray(option)) {
const picked = pickKeys(option as Record<string, unknown>, SELECT_OPTION_KEYS);
return picked;
}
return option;
});
}

function sanitizeTabs(tabs: unknown): unknown {
if (!Array.isArray(tabs)) return tabs;

return tabs.map((tab) => {
if (!tab || typeof tab !== 'object' || Array.isArray(tab)) return tab;
const picked = pickKeys(tab as Record<string, unknown>, TAB_KEYS);
const fields = Array.isArray((tab as { fields?: unknown }).fields)
? (tab as { fields: Field[] }).fields.map(sanitizeFieldForExport)
: [];
return {
...picked,
fields,
};
});
}

function sanitizeFieldForExport(field: Field): Field {
const allowedKeys = TYPE_KEYS[field.type];
if (!allowedKeys) return field;

const picked = pickKeys(field as unknown as Record<string, unknown>, allowedKeys);

if ('options' in picked) {
(picked as { options?: unknown }).options = sanitizeSelectOptions(
(picked as { options?: unknown }).options,
);
}

if ('fields' in picked) {
(picked as { fields?: unknown }).fields = Array.isArray(
(picked as { fields?: unknown }).fields,
)
? ((picked as { fields: Field[] }).fields.map(sanitizeFieldForExport))
: [];
}

if ('tabs' in picked) {
(picked as { tabs?: unknown }).tabs = sanitizeTabs(
(picked as { tabs?: unknown }).tabs,
);
}

return picked as unknown as Field;
}

/**
* Convert builder nodes to a BuzzForm Field array.
* Recursively processes the node tree and extracts field definitions.
Expand Down Expand Up @@ -87,20 +239,22 @@ export function nodeToField(nodes: Record<string, Node>, id: string): Field | nu
.map((childId) => nodeToField(nodes, childId))
.filter(Boolean) as Field[];

const tabRest = { ...(tab as unknown as Record<string, unknown>) };
delete tabRest.fields;
const cleanedTabRest = cleanEmptyValues(tabRest);

return {
...cleanedTabRest,
...(cleanEmptyValues(sanitizeFieldForExport({
type: 'tabs',
tabs: [tab],
} as Field)) as unknown as { tabs?: Array<Record<string, unknown>> })
.tabs?.[0],
fields: nestedFields,
};
});

return {
const fieldForExport = {
...(orderedField as Record<string, unknown>),
tabs: normalizedTabs,
} as Field;

return sanitizeFieldForExport(fieldForExport);
}

if (isContainerType(field.type) && 'fields' in field) {
Expand All @@ -110,13 +264,15 @@ export function nodeToField(nodes: Record<string, Node>, id: string): Field | nu
.filter(Boolean) as Field[]
: [];

return {
const fieldForExport = {
...orderedField,
fields: nestedFields,
} as Field;

return sanitizeFieldForExport(fieldForExport);
}

return orderedField as Field;
return sanitizeFieldForExport(orderedField as Field);
}

/**
Expand Down
Loading