diff --git a/packages/jsonforms-renderers/dev/fixtures.ts b/packages/jsonforms-renderers/dev/fixtures.ts index ddea32b..44d90fc 100644 --- a/packages/jsonforms-renderers/dev/fixtures.ts +++ b/packages/jsonforms-renderers/dev/fixtures.ts @@ -1231,7 +1231,9 @@ export const fixtures: Fixture[] = [ properties: { items: { type: 'array', - description: 'List of line items', + title: 'Line Items', + description: 'List of line items; minItems 1 shows a required asterisk', + minItems: 1, items: { type: 'object', properties: { @@ -1245,7 +1247,15 @@ export const fixtures: Fixture[] = [ }, uischema: { type: 'VerticalLayout', - elements: [{ type: 'Control', scope: '#/properties/items' }], + elements: [ + { + type: 'Control', + scope: '#/properties/items', + options: { + errorMessage: 'Add at least one line item before submitting.', + }, + }, + ], } as UISchemaElement, data: { items: [{ description: 'First item', qty: 1 }] }, }, diff --git a/packages/jsonforms-renderers/src/renderers/ArrayControl.test.tsx b/packages/jsonforms-renderers/src/renderers/ArrayControl.test.tsx index 16d59b0..3630be2 100644 --- a/packages/jsonforms-renderers/src/renderers/ArrayControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/ArrayControl.test.tsx @@ -8,12 +8,20 @@ vi.mock('@jsonforms/react', async (importOriginal) => { return { ...actual, JsonFormsDispatch: () => null } }) +// Unit tests render ArrayControl without a JsonForms provider; pass through +// the static `required` prop so asterisk assertions stay meaningful. +vi.mock('../hooks/useIsRequired', () => ({ + useIsRequired: (_path: string, staticRequired = false) => staticRequired, +})) + const itemsSchema: JsonSchema = { type: 'object', properties: { name: { type: 'string' } }, } -function renderArray(options?: ControlElement['options']) { +function renderArray(options?: ControlElement['options'], overrides: Record = {}) { + // Cast once at the boundary: JsonSchema includes draft-04 shapes that are not + // assignable to ArrayControlProps' JsonSchema7 fields (e.g. exclusiveMaximum). const props = { data: [{ name: 'a' }, { name: 'b' }], path: 'items', @@ -24,6 +32,9 @@ function renderArray(options?: ControlElement['options']) { addItem: () => () => undefined, removeItems: () => () => undefined, rootSchema: {}, + required: false, + errors: '', + ...overrides, } as unknown as ArrayControlProps return renderToStaticMarkup() } @@ -43,3 +54,57 @@ describe('ArrayControl itemLabel', () => { expect(html).not.toContain('Container 1') }) }) + +describe('ArrayControl required and errors', () => { + it('shows a red asterisk when required', () => { + const html = renderArray(undefined, { + required: true, + schema: { type: 'array', title: 'Commodities List', items: itemsSchema }, + }) + expect(html).toContain('Commodities List') + expect(html).toContain('*') + }) + + it('shows a red asterisk when minItems is at least 1', () => { + const html = renderArray(undefined, { + schema: itemsSchema, + arraySchema: { type: 'array', title: 'Commodities List', minItems: 1, items: itemsSchema }, + }) + expect(html).toContain('*') + }) + + it('renders options.errorMessage when the array is empty and has errors', () => { + const message = 'At least one commodity must be added before submitting the Phytosanitary Certificate application' + const html = renderArray( + { errorMessage: message }, + { + data: [], + errors: 'is a required property', + schema: itemsSchema, + arraySchema: { type: 'array', title: 'Commodities List', minItems: 1, items: itemsSchema }, + }, + ) + expect(html).toContain(message) + }) + + it('does not use options.errorMessage for non-empty array errors', () => { + const message = 'At least one commodity must be added before submitting the Phytosanitary Certificate application' + const html = renderArray( + { errorMessage: message }, + { + data: [{ name: 'a' }, { name: 'b' }, { name: 'c' }], + errors: 'must NOT have more than 2 items', + schema: itemsSchema, + arraySchema: { + type: 'array', + title: 'Commodities List', + minItems: 1, + maxItems: 2, + items: itemsSchema, + }, + }, + ) + expect(html).not.toContain(message) + expect(html).toContain('must NOT have more than 2 items') + }) +}) diff --git a/packages/jsonforms-renderers/src/renderers/ArrayControl.tsx b/packages/jsonforms-renderers/src/renderers/ArrayControl.tsx index 3be921c..b13c3f7 100644 --- a/packages/jsonforms-renderers/src/renderers/ArrayControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/ArrayControl.tsx @@ -3,6 +3,8 @@ import { withJsonFormsArrayControlProps, JsonFormsDispatch, useJsonForms } from import { useRef, type RefObject } from 'react' import { stampRowTemplates } from '../utils/sequence' import type { SequenceCounters } from '../utils/sequence' +import { getErrorMessage } from '../utils/error' +import { useIsRequired } from '../hooks/useIsRequired' import { Card, Button, Flex, Text, Box } from '@radix-ui/themes' import { PlusIcon, TrashIcon } from '@radix-ui/react-icons' @@ -56,6 +58,8 @@ export const ArrayControl = ({ removeItems, rootSchema, arraySchema, + required = false, + errors = '', }: ArrayControlProps) => { // Numbers already handed out to this array's numbered `x-template` fields. // A ref, not state: it must survive a row being removed, or the next row @@ -64,6 +68,9 @@ export const ArrayControl = ({ // row. It does NOT survive this control unmounting — see SequenceCounters // in utils/sequence.ts for what that costs and what covers it. const sequences = useRef({}) + // Above the early returns: hooks must run unconditionally. Also covers + // arrays that become required through allOf/if/then (#112). + const conditionallyRequired = useIsRequired(path, required, visible) // If `arraySchema` is present, `schema` is already our `itemsSchema`, else fall back to `schema.items` const itemsSchema = arraySchema ? schema : schema.items @@ -81,10 +88,24 @@ export const ArrayControl = ({ const items = Array.isArray(data) ? data : [] const title = actualArraySchema.title || 'Array Items' - const options = (uischema.options ?? {}) as { addable?: boolean; removable?: boolean; itemLabel?: string } + const options = (uischema.options ?? {}) as { + addable?: boolean + removable?: boolean + itemLabel?: string + errorMessage?: string + } const canAdd = enabled && options.addable !== false const canRemove = enabled && options.removable !== false const itemLabel = options.itemLabel || 'Item' + // minItems >= 1 is separate from schema `required`: an empty array fails + // with a minItems error, not a required error, so useIsRequired alone + // would miss it. + const showRequired = + conditionallyRequired || (typeof actualArraySchema.minItems === 'number' && actualArraySchema.minItems >= 1) + const isValid = !errors + // options.errorMessage is for the empty-array case (required / minItems). + // Other array errors (e.g. maxItems) keep AJV's message. + const displayError = !isValid ? (items.length === 0 && options.errorMessage) || getErrorMessage(errors, title) : '' const handleRemoveItem = (indexToRemove: number) => { if (removeItems) { @@ -97,7 +118,7 @@ export const ArrayControl = ({ - {title} + {title} {showRequired && *} {items.length === 0 && ( @@ -108,6 +129,12 @@ export const ArrayControl = ({ )} + {!isValid && ( + + {displayError} + + )} + {items.map((_item, index) => { const childPath = `${path}.${index}` return (