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
14 changes: 14 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'designer.object.iconHint': 'Lucide icon name (e.g. “building”, “users”)',
'designer.object.description': 'Description',
'designer.object.descriptionPlaceholder': 'What this object represents',
// Field ergonomics (Tier 2)
'designer.field.defaultValue': 'Default value',
'designer.field.defaultNone': '— None —',
'designer.field.true': 'True',
'designer.field.false': 'False',
'designer.field.duplicate': 'Duplicate field',
'designer.field.copySuffix': ' copy',
};

const ENGINE_STRINGS_ZH: Record<string, string> = {
Expand Down Expand Up @@ -999,6 +1006,13 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'designer.object.iconHint': 'Lucide 图标名称(如 “building”、“users”)',
'designer.object.description': '描述',
'designer.object.descriptionPlaceholder': '该对象代表什么',
// Field ergonomics (Tier 2)
'designer.field.defaultValue': '默认值',
'designer.field.defaultNone': '— 无 —',
'designer.field.true': '是',
'designer.field.false': '否',
'designer.field.duplicate': '复制字段',
'designer.field.copySuffix': ' 副本',
};

function pickTable(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';

// The inspector loads the object list for the lookup picker; stub it.
vi.mock('../useMetadata', () => ({
useMetadataClient: () => ({ list: vi.fn().mockResolvedValue([]) }),
}));

import { ObjectFieldInspector } from './ObjectFieldInspector';

afterEach(cleanup);

function renderField(
fields: Record<string, Record<string, unknown>>,
selectedId: string,
overrides: Record<string, unknown> = {},
) {
const onPatch = vi.fn();
const onSelectionChange = vi.fn();
const utils = render(
<ObjectFieldInspector
type="object"
name="account"
draft={{ name: 'account', fields }}
selection={{ kind: 'field', id: selectedId }}
onPatch={onPatch}
onClearSelection={vi.fn()}
onSelectionChange={onSelectionChange}
readOnly={false}
locale={'en-US'}
{...overrides}
/>,
);
return { onPatch, onSelectionChange, ...utils };
}

function controlFor(label: string): HTMLElement {
const lab = screen.getByText(label);
return lab.parentElement!.querySelector('input, textarea, select, [role="combobox"]') as HTMLElement;
}

describe('ObjectFieldInspector — duplicate field', () => {
it('clones the field below itself with a unique name and selects it', () => {
const { onPatch, onSelectionChange } = renderField(
{ email: { type: 'email', label: 'Email' } },
'email',
);
fireEvent.click(screen.getByRole('button', { name: 'Duplicate field' }));
const patch = onPatch.mock.calls.at(-1)![0];
expect(Object.keys(patch.fields)).toEqual(['email', 'email_copy']);
expect(patch.fields.email_copy).toMatchObject({ type: 'email', label: 'Email copy' });
expect(onSelectionChange).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'field', id: 'email_copy' }),
);
});

it('avoids name collisions when a copy already exists', () => {
const { onPatch } = renderField(
{ email: { type: 'email' }, email_copy: { type: 'email' } },
'email',
);
fireEvent.click(screen.getByRole('button', { name: 'Duplicate field' }));
const patch = onPatch.mock.calls.at(-1)![0];
expect(Object.keys(patch.fields)).toContain('email_copy_2');
});
});

describe('ObjectFieldInspector — default value', () => {
it('commits a text default for a text field', () => {
const { onPatch } = renderField({ note: { type: 'text', label: 'Note' } }, 'note');
fireEvent.change(controlFor('Default value'), { target: { value: 'hello' } });
expect(onPatch).toHaveBeenCalledWith(
expect.objectContaining({ fields: expect.anything() }),
);
const patch = onPatch.mock.calls.at(-1)![0];
expect(patch.fields.note.defaultValue).toBe('hello');
});

it('offers a select (not a text box) default for a boolean field', () => {
renderField({ active: { type: 'boolean', label: 'Active' } }, 'active');
expect(screen.getByText('Default value')).toBeInTheDocument();
// Boolean default uses a tri-state Select (— / True / False), so the
// control is a combobox rather than a free-text input.
const ctrl = controlFor('Default value');
expect(ctrl.getAttribute('role')).toBe('combobox');
});

it('omits the default-value editor for computed fields', () => {
renderField({ total: { type: 'formula', label: 'Total' } }, 'total');
expect(screen.queryByText('Default value')).not.toBeInTheDocument();
});

it('omits the default-value editor for lookup fields', () => {
renderField({ owner: { type: 'lookup', label: 'Owner' } }, 'owner');
expect(screen.queryByText('Default value')).not.toBeInTheDocument();
});
});

describe('ObjectFieldInspector — read-only', () => {
it('hides the duplicate action when read-only', () => {
renderField({ email: { type: 'email' } }, 'email', { readOnly: true });
expect(screen.queryByRole('button', { name: 'Duplicate field' })).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
moveArray,
} from './_shared';
import { Button, Input, Label, Badge } from '@object-ui/components';
import { Plus, X, ArrowUp, ArrowDown } from 'lucide-react';
import { Plus, X, ArrowUp, ArrowDown, Copy } from 'lucide-react';
import {
readFields,
writeFields,
Expand Down Expand Up @@ -95,6 +95,28 @@ function isTexty(type: string): boolean {
return type === 'text' || type === 'textarea' || type === 'email' || type === 'url' || type === 'phone' || type === 'password';
}

type DefaultKind = 'bool' | 'number' | 'picklist' | 'text';

/**
* Which default-value editor (if any) fits a field type. Computed,
* relational, media and structural types have no meaningful literal
* default in this UI, so they return null (no editor rendered).
*/
function defaultValueKind(type: string): DefaultKind | null {
if (type === 'boolean' || type === 'toggle') return 'bool';
if (type === 'number' || type === 'currency' || type === 'percent') return 'number';
if (type === 'select' || type === 'radio') return 'picklist';
const noDefault = [
'formula', 'summary', 'autonumber',
'lookup', 'master_detail', 'tree',
'file', 'image', 'avatar', 'video', 'audio', 'signature', 'qrcode',
'composite', 'repeater', 'vector',
'multiselect', 'checkboxes', 'tags',
];
if (noDefault.includes(type)) return null;
return 'text';
}

function buildTypeOptions(locale?: string): Array<{ value: string; label: string }> {
const zh = (locale ?? '').toLowerCase().startsWith('zh');
const cats = zh ? CATEGORY_LABEL_ZH : CATEGORY_LABEL_EN;
Expand Down Expand Up @@ -176,6 +198,25 @@ export function ObjectFieldInspector({
onClearSelection();
};

const duplicateField = () => {
// Clone the field below itself with a collision-free name and a
// "(copy)" label, then select the clone so it's ready to tweak.
const existing = new Set(view.entries.map((e) => e.name));
const base = `${entry.name}_copy`;
let name = base;
let n = 1;
while (existing.has(name)) { n += 1; name = `${base}_${n}`; }
const labelStr = typeof def.label === 'string' && def.label ? def.label : '';
const clone: FieldEntry = {
name,
def: { ...def, label: labelStr ? labelStr + tr('designer.field.copySuffix') : undefined },
};
const nextEntries = [...view.entries];
nextEntries.splice(idx + 1, 0, clone);
writeView({ shape: view.shape, entries: nextEntries });
onSelectionChange?.({ kind: 'field', id: name, label: String(clone.def.label ?? name) });
};

const moveTo = (toIndex: number) => {
const next = { shape: view.shape, entries: moveArray(view.entries, idx, toIndex) };
writeView(next);
Expand All @@ -198,12 +239,26 @@ export function ObjectFieldInspector({
/* ─── Render ─── */

const headerActions = (
<InspectorReorderButtons
index={idx}
total={view.entries.length}
onMove={moveTo}
disabled={readOnly}
/>
<div className="flex items-center gap-1">
{!readOnly && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={duplicateField}
title={tr('designer.field.duplicate')}
aria-label={tr('designer.field.duplicate')}
>
<Copy className="h-3.5 w-3.5" />
</Button>
)}
<InspectorReorderButtons
index={idx}
total={view.entries.length}
onMove={moveTo}
disabled={readOnly}
/>
</div>
);

const footer = (
Expand Down Expand Up @@ -270,6 +325,16 @@ export function ObjectFieldInspector({
disabled={readOnly}
rows={2}
/>
{defaultValueKind(type) && (
<DefaultValueField
kind={defaultValueKind(type)!}
value={def.defaultValue}
options={options}
onCommit={(v) => patchDef({ defaultValue: v })}
disabled={readOnly}
locale={locale}
/>
)}
</Section>

{/* Type-specific */}
Expand Down Expand Up @@ -425,6 +490,80 @@ function Section({ title, children }: { title: string; children: React.ReactNode
);
}

/** Type-aware default-value editor. Stores the literal on `Field.defaultValue`. */
function DefaultValueField({
kind,
value,
options,
onCommit,
disabled,
locale,
}: {
kind: DefaultKind;
value: unknown;
options: Option[];
onCommit: (v: unknown) => void;
disabled?: boolean;
locale?: string;
}) {
const label = t('designer.field.defaultValue', locale);
const none = t('designer.field.defaultNone', locale);

if (kind === 'bool') {
const cur = value === true ? 'true' : value === false ? 'false' : '';
return (
<InspectorSelectField
label={label}
value={cur}
options={[
{ value: '', label: none },
{ value: 'true', label: t('designer.field.true', locale) },
{ value: 'false', label: t('designer.field.false', locale) },
]}
onCommit={(v) => onCommit(v === '' ? undefined : v === 'true')}
disabled={disabled}
/>
);
}

if (kind === 'number') {
return (
<InspectorNumberField
label={label}
value={typeof value === 'number' ? value : undefined}
onCommit={(v) => onCommit(v)}
disabled={disabled}
/>
);
}

if (kind === 'picklist') {
return (
<InspectorSelectField
label={label}
value={typeof value === 'string' ? value : ''}
options={[
{ value: '', label: none },
...options
.filter((o) => o.value)
.map((o) => ({ value: o.value, label: o.label || o.value })),
]}
onCommit={(v) => onCommit(v || undefined)}
disabled={disabled}
/>
);
}

return (
<InspectorTextField
label={label}
value={typeof value === 'string' ? value : value == null ? '' : String(value)}
onCommit={(v) => onCommit(v || undefined)}
disabled={disabled}
/>
);
}

function TextareaField({
label,
value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,43 @@ describe('ObjectFormCanvas — empty state', () => {
expect(screen.queryByText('fields')).not.toBeInTheDocument();
});
});

describe('ObjectFormCanvas — keyboard reorder (Alt+↑/↓)', () => {
const draft = {
name: 'x',
fields: {
alpha: { type: 'text', label: 'Alpha' },
beta: { type: 'text', label: 'Beta' },
},
};
const rowFor = (label: string) => screen.getByText(label).closest('[role="button"]')!;

it('Alt+ArrowDown swaps a field with the next one', () => {
const onPatch = vi.fn();
render(<ObjectFormCanvas objectName="x" draft={draft} onPatch={onPatch} />);
fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowDown', altKey: true });
const patch = onPatch.mock.calls.at(-1)![0];
expect(Object.keys(patch.fields)).toEqual(['beta', 'alpha']);
});

it('Alt+ArrowUp on the first field is a no-op', () => {
const onPatch = vi.fn();
render(<ObjectFormCanvas objectName="x" draft={draft} onPatch={onPatch} />);
fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowUp', altKey: true });
expect(onPatch).not.toHaveBeenCalled();
});

it('plain ArrowDown (no Alt) does not reorder', () => {
const onPatch = vi.fn();
render(<ObjectFormCanvas objectName="x" draft={draft} onPatch={onPatch} />);
fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowDown' });
expect(onPatch).not.toHaveBeenCalled();
});

it('is inert when read-only (no onPatch)', () => {
render(<ObjectFormCanvas objectName="x" draft={draft} />);
// No throw, and the row is not draggable/reorderable.
fireEvent.keyDown(rowFor('Alpha'), { key: 'ArrowDown', altKey: true });
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
});
Loading
Loading