diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 438549b09..ea951a6ea 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -577,6 +577,12 @@ const ENGINE_STRINGS_EN: Record = { 'designer.field.false': 'False', 'designer.field.duplicate': 'Duplicate field', 'designer.field.copySuffix': ' copy', + // Field power props (Tier 2 — conditional & validation) + 'designer.field.helpText': 'Help text', + 'designer.field.helpTextPlaceholder': 'Shown below the field on the form', + 'designer.field.minLength': 'Min length', + 'designer.field.conditionalRequired': 'Required when (CEL)', + 'designer.field.conditionalRequiredHint': 'Field becomes required when this predicate is true.', }; const ENGINE_STRINGS_ZH: Record = { @@ -1013,6 +1019,12 @@ const ENGINE_STRINGS_ZH: Record = { 'designer.field.false': '否', 'designer.field.duplicate': '复制字段', 'designer.field.copySuffix': ' 副本', + // Field power props (Tier 2 — conditional & validation) + 'designer.field.helpText': '帮助文本', + 'designer.field.helpTextPlaceholder': '显示在表单字段下方', + 'designer.field.minLength': '最小长度', + 'designer.field.conditionalRequired': '条件必填 (CEL)', + 'designer.field.conditionalRequiredHint': '当此表达式为真时,该字段变为必填。', }; function pickTable( diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx index db76155bf..e068436bb 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx @@ -98,9 +98,44 @@ describe('ObjectFieldInspector — default value', () => { }); }); +describe('ObjectFieldInspector — power props (conditional & validation)', () => { + it('commits inline help text', () => { + const { onPatch } = renderField({ note: { type: 'text', label: 'Note' } }, 'note'); + fireEvent.change(controlFor('Help text'), { target: { value: 'Enter the note' } }); + expect(onPatch.mock.calls.at(-1)![0].fields.note.inlineHelpText).toBe('Enter the note'); + }); + + it('commits min length for text fields (alongside max length)', () => { + const { onPatch } = renderField({ note: { type: 'text' } }, 'note'); + expect(screen.getByText('Min length')).toBeInTheDocument(); + expect(screen.getByText('Max length')).toBeInTheDocument(); + fireEvent.change(controlFor('Min length'), { target: { value: '3' } }); + expect(onPatch.mock.calls.at(-1)![0].fields.note.minLength).toBe(3); + }); + + it('commits a conditional-required CEL predicate', () => { + const { onPatch } = renderField({ note: { type: 'text' } }, 'note'); + fireEvent.change(controlFor('Required when (CEL)'), { target: { value: 'record.x == 1' } }); + expect(onPatch.mock.calls.at(-1)![0].fields.note.conditionalRequired).toBe('record.x == 1'); + }); + + it('offers conditional-required for non-text types too', () => { + renderField({ active: { type: 'boolean' } }, 'active'); + expect(screen.getByText('Required when (CEL)')).toBeInTheDocument(); + // Min length is text-only, so it should not show for a boolean. + expect(screen.queryByText('Min length')).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(); }); + + it('disables the power-prop inputs when read-only', () => { + renderField({ note: { type: 'text' } }, 'note', { readOnly: true }); + expect(controlFor('Help text')).toBeDisabled(); + expect(controlFor('Required when (CEL)')).toBeDisabled(); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index e43c2c797..8031a39d3 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -335,6 +335,14 @@ export function ObjectFieldInspector({ locale={locale} /> )} + patchDef({ inlineHelpText: v || undefined })} + disabled={readOnly} + rows={2} + placeholder={tr('designer.field.helpTextPlaceholder')} + /> {/* Type-specific */} @@ -407,13 +415,22 @@ export function ObjectFieldInspector({ )} {isTexty(type) && ( - patchDef({ maxLength: v })} - disabled={readOnly} - placeholder="255" - /> +
+ patchDef({ minLength: v })} + disabled={readOnly} + placeholder="0" + /> + patchDef({ maxLength: v })} + disabled={readOnly} + placeholder="255" + /> +
)} )} @@ -458,6 +475,19 @@ export function ObjectFieldInspector({ onCommit={(v) => patchDef({ placeholder: v || undefined })} disabled={readOnly} /> +
+ patchDef({ conditionalRequired: v || undefined })} + disabled={readOnly} + mono + placeholder="record.status == 'closed'" + /> +

+ {tr('designer.field.conditionalRequiredHint')} +

+
{fieldGroups.length > 0 && (