Skip to content
Merged
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
3 changes: 2 additions & 1 deletion content/docs/references/ai/meta.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"title": "AI Protocol"
"title": "AI Protocol",
"root": true
}
1 change: 1 addition & 0 deletions content/docs/references/data/meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"title": "Data Protocol",
"root": true,
"pages": [
"core",
"logic",
Expand Down
1 change: 0 additions & 1 deletion content/docs/references/meta.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"label": "Protocol Reference",
"root": true,
"order": 100,
"pages": [
"data",
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/system/meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"title": "System Protocol",
"root": true,
"pages": [
"identity",
"integration",
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/ui/meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"title": "UI Protocol",
"root": true,
"pages": [
"app",
"views",
Expand Down
121 changes: 120 additions & 1 deletion packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
FieldSchema,
FieldType,
SelectOptionSchema,
type Field,
Field,
type SelectOption
} from './field.zod';

Expand Down Expand Up @@ -304,3 +304,122 @@ describe('FieldSchema', () => {
});
});
});

describe('Field Factory Helpers', () => {
describe('Basic Field Types', () => {
it('should create phone field', () => {
const phoneField = Field.phone({ label: 'Mobile Phone', required: true });

expect(phoneField.type).toBe('phone');
expect(phoneField.label).toBe('Mobile Phone');
expect(phoneField.required).toBe(true);
});

it('should create text field', () => {
const textField = Field.text({ label: 'Name', maxLength: 100 });

expect(textField.type).toBe('text');
expect(textField.label).toBe('Name');
expect(textField.maxLength).toBe(100);
});

it('should create email field', () => {
const emailField = Field.email({ label: 'Email Address' });

expect(emailField.type).toBe('email');
expect(emailField.label).toBe('Email Address');
});
});

describe('Select Field Factory', () => {
it('should create select field with string array (old API)', () => {
const selectField = Field.select(['High', 'Medium', 'Low'], { label: 'Priority' });

expect(selectField.type).toBe('select');
expect(selectField.label).toBe('Priority');
expect(selectField.options).toHaveLength(3);
expect(selectField.options[0]).toEqual({ label: 'High', value: 'High' });
});

it('should create select field with SelectOption array in config (new API)', () => {
const selectField = Field.select({
label: 'Priority',
options: [
{ label: 'High Priority', value: 'high', color: '#FF0000' },
{ label: 'Low Priority', value: 'low', color: '#00FF00' },
],
});

expect(selectField.type).toBe('select');
expect(selectField.label).toBe('Priority');
expect(selectField.options).toHaveLength(2);
expect(selectField.options[0].color).toBe('#FF0000');
expect(selectField.options[1].value).toBe('low');
});

it('should create select field with mixed string/object array (new API)', () => {
const selectField = Field.select({
label: 'Status',
options: [
{ label: 'Active', value: 'active', color: '#00AA00' },
'Inactive',
'Pending',
],
});

expect(selectField.type).toBe('select');
expect(selectField.options).toHaveLength(3);
expect(selectField.options[0]).toEqual({ label: 'Active', value: 'active', color: '#00AA00' });
expect(selectField.options[1]).toEqual({ label: 'Inactive', value: 'Inactive' });
expect(selectField.options[2]).toEqual({ label: 'Pending', value: 'Pending' });
});
});

describe('Multiselect Field Factory', () => {
it('should create multiselect field with string array (old API)', () => {
const multiselectField = Field.multiselect(['Tag1', 'Tag2', 'Tag3'], { label: 'Tags' });

expect(multiselectField.type).toBe('multiselect');
expect(multiselectField.label).toBe('Tags');
expect(multiselectField.options).toHaveLength(3);
});

it('should create multiselect field with SelectOption array (new API)', () => {
const multiselectField = Field.multiselect({
label: 'Categories',
options: [
{ label: 'Technology', value: 'tech' },
{ label: 'Business', value: 'biz' },
],
});

expect(multiselectField.type).toBe('multiselect');
expect(multiselectField.options).toHaveLength(2);
expect(multiselectField.options[0].value).toBe('tech');
});
});

describe('Lookup and Master-Detail Fields', () => {
it('should create lookup field', () => {
const lookupField = Field.lookup('account', {
label: 'Account',
referenceFilters: ['status = "active"'],
});

expect(lookupField.type).toBe('lookup');
expect(lookupField.reference).toBe('account');
expect(lookupField.label).toBe('Account');
});

it('should create master_detail field', () => {
const masterDetailField = Field.master_detail('parent_object', {
label: 'Parent',
deleteBehavior: 'cascade',
});

expect(masterDetailField.type).toBe('master_detail');
expect(masterDetailField.reference).toBe('parent_object');
expect(masterDetailField.deleteBehavior).toBe('cascade');
});
});
});
69 changes: 59 additions & 10 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const Field = {
percent: (config: FieldInput = {}) => ({ type: 'percent', ...config } as const),
url: (config: FieldInput = {}) => ({ type: 'url', ...config } as const),
email: (config: FieldInput = {}) => ({ type: 'email', ...config } as const),
phone: (config: FieldInput = {}) => ({ type: 'phone', ...config } as const),
image: (config: FieldInput = {}) => ({ type: 'image', ...config } as const),
file: (config: FieldInput = {}) => ({ type: 'file', ...config } as const),
avatar: (config: FieldInput = {}) => ({ type: 'avatar', ...config } as const),
Expand All @@ -121,17 +122,65 @@ export const Field = {
html: (config: FieldInput = {}) => ({ type: 'html', ...config } as const),
password: (config: FieldInput = {}) => ({ type: 'password', ...config } as const),

select: (options: string[], config: FieldInput = {}) => ({
type: 'select',
options: options.map(o => ({ label: o, value: o })),
...config
} as const),
/**
* Select field helper with backward-compatible API
*
* @example Old API (array first)
* Field.select(['High', 'Low'], { label: 'Priority' })
*
* @example New API (config object)
* Field.select({ options: [{label: 'High', value: 'high'}], label: 'Priority' })
*/
select: (optionsOrConfig: SelectOption[] | string[] | FieldInput & { options: SelectOption[] | string[] }, config?: FieldInput) => {
// Support both old and new signatures:
// Old: Field.select(['a', 'b'], { label: 'X' })
// New: Field.select({ options: [{label: 'A', value: 'a'}], label: 'X' })
let options: SelectOption[];
let finalConfig: FieldInput;

if (Array.isArray(optionsOrConfig)) {
// Old signature: array as first param
options = optionsOrConfig.map(o => typeof o === 'string' ? { label: o, value: o } : o);
finalConfig = config || {};
} else {
// New signature: config object with options
options = (optionsOrConfig.options || []).map(o => typeof o === 'string' ? { label: o, value: o } : o);
// Remove options from config to avoid confusion
const { options: _, ...restConfig } = optionsOrConfig;
finalConfig = restConfig;
}

return { type: 'select', options, ...finalConfig } as const;
},

multiselect: (options: string[], config: FieldInput = {}) => ({
type: 'multiselect',
options: options.map(o => ({ label: o, value: o })),
...config
} as const),
/**
* Multiselect field helper with backward-compatible API
*
* @example Old API (array first)
* Field.multiselect(['Tag1', 'Tag2'], { label: 'Tags' })
*
* @example New API (config object)
* Field.multiselect({ options: [{label: 'Tag 1', value: 'tag1'}], label: 'Tags' })
*/
multiselect: (optionsOrConfig: SelectOption[] | string[] | FieldInput & { options: SelectOption[] | string[] }, config?: FieldInput) => {
// Support both old and new signatures
let options: SelectOption[];
let finalConfig: FieldInput;

if (Array.isArray(optionsOrConfig)) {
// Old signature: array as first param
options = optionsOrConfig.map(o => typeof o === 'string' ? { label: o, value: o } : o);
finalConfig = config || {};
} else {
// New signature: config object with options
options = (optionsOrConfig.options || []).map(o => typeof o === 'string' ? { label: o, value: o } : o);
// Remove options from config to avoid confusion
const { options: _, ...restConfig } = optionsOrConfig;
finalConfig = restConfig;
}

return { type: 'multiselect', options, ...finalConfig } as const;
},

lookup: (reference: string, config: FieldInput = {}) => ({
type: 'lookup',
Expand Down
Loading