Add rank selection and condition handling to certificate designer and requirements

This commit is contained in:
Nati
2026-08-24 07:39:24 +00:00
parent 1af7e90c21
commit 302c0242dd
7 changed files with 359 additions and 147 deletions

View File

@@ -1,13 +1,17 @@
import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core'; import { Button, Group, NumberInput, Select, Tooltip } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalized, type LicenseType } from '@ema-platform/api'; import { useLocalized, type LicenseType, type Rank } from '@ema-platform/api';
import { groupedTypeOptions } from '../config/designer'; import { groupedTypeOptions } from '../config/designer';
interface Props { interface Props {
licenseTypes: LicenseType[]; licenseTypes: LicenseType[];
typeId: string | null; typeId: string | null;
onTypeChange: (id: string | null) => void; onTypeChange: (id: string | null) => void;
/** The selected licence type's rank ladder — empty for non-CoC/CoP types. */
ranks: Rank[];
rankId: string | null;
onRankChange: (id: string | null) => void;
validityMonths: number; validityMonths: number;
onValidityChange: (months: number) => void; onValidityChange: (months: number) => void;
currentValidityMonths?: number | null; currentValidityMonths?: number | null;
@@ -22,6 +26,9 @@ export function DesignerToolbar({
licenseTypes, licenseTypes,
typeId, typeId,
onTypeChange, onTypeChange,
ranks,
rankId,
onRankChange,
validityMonths, validityMonths,
onValidityChange, onValidityChange,
currentValidityMonths, currentValidityMonths,
@@ -49,6 +56,23 @@ export function DesignerToolbar({
w={340} w={340}
/> />
{/* CoC/CoP only — a rank can carry its own design (e.g. Master's
certificate differs from an OOW's). "Default" (null) is the design
every other rank under the type falls back to. */}
{ranks.length > 0 && (
<Select
label={t('designer.rank', 'Rank')}
description={t('designer.rankHint', 'Leave as Default to design for every rank')}
data={[
{ value: '', label: t('designer.rankDefault', 'Default (all ranks)') },
...ranks.map((r) => ({ value: r.id, label: localized(r.name) })),
]}
value={rankId ?? ''}
onChange={(value) => onRankChange(value || null)}
w={220}
/>
)}
{/* Validity lives beside the design because it is the other half of {/* Validity lives beside the design because it is the other half of
what a certificate promises. */} what a certificate promises. */}
<NumberInput <NumberInput

View File

@@ -23,9 +23,11 @@ import {
useArchiveLicenseTemplateMutation, useArchiveLicenseTemplateMutation,
useCreateLicenseTemplateMutation, useCreateLicenseTemplateMutation,
useDeleteLicenseTemplateMutation, useDeleteLicenseTemplateMutation,
useGetActiveDepartmentsQuery,
useGetBuiltInTemplateQuery, useGetBuiltInTemplateQuery,
useGetLicenseTemplatesQuery, useGetLicenseTemplatesQuery,
useGetLicenseTypesQuery, useGetLicenseTypesQuery,
useGetRankLadderQuery,
useGetTemplateVariablesQuery, useGetTemplateVariablesQuery,
usePublishLicenseTemplateMutation, usePublishLicenseTemplateMutation,
useUpdateLicenseValidityMutation, useUpdateLicenseValidityMutation,
@@ -66,14 +68,19 @@ export function CertificateDesignerPage() {
const { data: licenseTypes } = useGetLicenseTypesQuery(); const { data: licenseTypes } = useGetLicenseTypesQuery();
const [typeId, setTypeId] = useState<string | null>(null); const [typeId, setTypeId] = useState<string | null>(null);
const [rankId, setRankId] = useState<string | null>(null);
const { const {
data: templates = [], data: allTemplates = [],
isLoading, isLoading,
isError, isError,
error, error,
refetch, refetch,
} = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId }); } = useGetLicenseTemplatesQuery(typeId ?? undefined, { skip: !typeId });
// The list is per licence type; a rank-specific design and the type's
// default both come back, so the version list is scoped to whichever the
// toolbar has selected.
const templates = allTemplates.filter((tpl) => (tpl.rankId ?? null) === rankId);
const { data: variables = [] } = useGetTemplateVariablesQuery(); const { data: variables = [] } = useGetTemplateVariablesQuery();
const { data: builtIn } = useGetBuiltInTemplateQuery(); const { data: builtIn } = useGetBuiltInTemplateQuery();
@@ -95,6 +102,20 @@ export function CertificateDesignerPage() {
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId); const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
// A rank ladder only exists for CoC/CoP — every other licence type designs
// one certificate for everyone who holds it.
const isRankScoped =
selectedType?.certificateCategory === 'COC' || selectedType?.certificateCategory === 'COP';
const { data: departments } = useGetActiveDepartmentsQuery(undefined, { skip: !isRankScoped });
const department = departments?.find((d) => d.code === selectedType?.stcwDepartment);
const { data: ranks = [] } = useGetRankLadderQuery(
{
departmentId: department?.id ?? '',
certificateCategory: (selectedType?.certificateCategory ?? 'COC') as 'COC' | 'COP',
},
{ skip: !isRankScoped || !department },
);
// Default to the first licence type so the page is never an empty shell. // Default to the first licence type so the page is never an empty shell.
useEffect(() => { useEffect(() => {
if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id); if (!typeId && licenseTypes?.items?.length) setTypeId(licenseTypes.items[0].id);
@@ -104,6 +125,12 @@ export function CertificateDesignerPage() {
if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12); if (selectedType) setValidityMonths(selectedType.validityMonths ?? 12);
}, [selectedType]); }, [selectedType]);
// Switching licence type leaves a stale rank selected from the previous
// type's ladder — reset to the type's default design.
useEffect(() => {
setRankId(null);
}, [typeId]);
function startNewVersion() { function startNewVersion() {
setNewName( setNewName(
`${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`, `${selectedType?.name?.en ?? 'Certificate'} v${(templates[0]?.version ?? 0) + 1}`,
@@ -130,6 +157,12 @@ export function CertificateDesignerPage() {
setTypeId(value); setTypeId(value);
draft.setSelectedId(null); draft.setSelectedId(null);
}} }}
ranks={ranks}
rankId={rankId}
onRankChange={(value) => {
setRankId(value);
draft.setSelectedId(null);
}}
validityMonths={validityMonths} validityMonths={validityMonths}
onValidityChange={setValidityMonths} onValidityChange={setValidityMonths}
currentValidityMonths={selectedType?.validityMonths} currentValidityMonths={selectedType?.validityMonths}
@@ -369,6 +402,7 @@ export function CertificateDesignerPage() {
run(async () => { run(async () => {
const created = await createTemplate({ const created = await createTemplate({
licenseTypeId: typeId as string, licenseTypeId: typeId as string,
rankId,
name: newName.trim(), name: newName.trim(),
hbsSource: templates.length ? undefined : builtIn?.hbsSource, hbsSource: templates.length ? undefined : builtIn?.hbsSource,
}).unwrap(); }).unwrap();

View File

@@ -1,4 +1,5 @@
import { Autocomplete, Checkbox, Group, Select, Stack, Switch, Text, TextInput } from '@mantine/core'; import { ActionIcon, Autocomplete, Checkbox, Group, Paper, Select, Stack, Switch, Text, TextInput } from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api'; import type { FieldCondition, FormSchemaPalette } from '@ema-platform/api';
import { useLocalized } from '@ema-platform/api'; import { useLocalized } from '@ema-platform/api';
@@ -7,6 +8,9 @@ import type { ConditionTarget } from '../config/schema-paths';
/** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */ /** `FieldCondition` plus the renewal-only extra `DocumentRequirement.conditionExpression` carries. */
export type ConditionValue = FieldCondition & { previousDocExpired?: string }; export type ConditionValue = FieldCondition & { previousDocExpired?: string };
/** One editable `anyOf` arm — a single-field condition, same shape a plain condition holds. */
type ConditionArm = Omit<ConditionValue, 'anyOf' | 'previousDocExpired'>;
type Operator = 'equals' | 'notEquals' | 'in' | 'isSet'; type Operator = 'equals' | 'notEquals' | 'in' | 'isSet';
function operatorOf(condition: ConditionValue | undefined): Operator | null { function operatorOf(condition: ConditionValue | undefined): Operator | null {
@@ -29,6 +33,196 @@ function coerce(raw: string, targetType: string | undefined): string | number |
return raw; return raw;
} }
/** One-line summary of a condition for read-only chips ("when X = Y", "when X = Y or W = Z"). */
export function describeCondition(
condition: ConditionValue,
t: (key: string, fallback: string) => string,
): string {
if (condition.anyOf?.length) {
return condition.anyOf.map((arm) => describeCondition(arm, t)).join(` ${t('certReq.condition.or', 'or')} `);
}
if (!condition.field) return '';
const parts = [condition.field];
if (condition.equals !== undefined) parts.push(`= ${condition.equals}`);
if (condition.notEquals !== undefined) parts.push(`${condition.notEquals}`);
if (condition.in !== undefined) parts.push(`∈ [${condition.in.join(', ')}]`);
if (condition.isSet !== undefined) {
parts.push(condition.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'));
}
return parts.join(' ');
}
/**
* The field/operator/value trio for one condition — a plain condition, or one
* arm of an `anyOf`. No enable switch of its own; the caller owns whether
* this row exists at all.
*/
function ConditionArmFields({
value,
onChange,
targets,
palette,
}: {
value: ConditionArm;
onChange: (value: ConditionArm) => void;
targets: ConditionTarget[];
palette: FormSchemaPalette | undefined;
}) {
const { t } = useTranslation();
const localized = useLocalized();
const operator = operatorOf(value) ?? 'equals';
const target = targets.find((c) => c.path === value.field);
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
function setField(field: string) {
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) });
}
function setOperator(next: Operator) {
if (!value.field) return;
const base: ConditionArm = { field: value.field };
if (next === 'isSet') base.isSet = true;
else if (next === 'in') base.in = [];
else if (next === 'notEquals') base.notEquals = '';
else base.equals = '';
onChange(base);
}
function setValueRaw(raw: string) {
if (!value.field) return;
const coerced = coerce(raw, target?.field.type);
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
}
function setInValues(raws: string[]) {
if (!value.field) return;
onChange({
field: value.field,
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[],
});
}
return (
<Stack gap="xs">
<Autocomplete
label={t('certReq.condition.field', 'Field path')}
placeholder="certificate.rank"
description={t(
'certReq.condition.fieldHelp',
'Dot path into the form, e.g. sectionKey.fieldKey',
)}
data={targets.map((c) => c.path)}
value={value.field ?? ''}
onChange={setField}
/>
<Group grow align="flex-start">
<Select
label={t('certReq.condition.operator', 'Operator')}
data={operators.map((op) => ({ value: op, label: op }))}
value={operator}
onChange={(v) => v && setOperator(v as Operator)}
allowDeselect={false}
/>
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && (
<Select
label={t('certReq.condition.value', 'Value')}
data={(target.field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label) || o.value,
}))}
value={String(value.equals ?? value.notEquals ?? '')}
onChange={(v) => v !== null && setValueRaw(v)}
/>
)}
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && (
target?.field.type === 'BOOLEAN' ? (
<Checkbox
mt="xl"
label={t('certReq.condition.value', 'Value')}
checked={Boolean(value.equals ?? value.notEquals ?? false)}
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
/>
) : (
<TextInput
label={t('certReq.condition.value', 'Value')}
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
value={String(value.equals ?? value.notEquals ?? '')}
onChange={(e) => setValueRaw(e.currentTarget.value)}
/>
)
)}
{operator === 'in' && target?.field.type === 'SELECT' && (
<Select
label={t('certReq.condition.values', 'Any of')}
data={(target.field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label) || o.value,
}))}
multiple={undefined}
value={null}
onChange={(v) => {
if (!v) return;
const current = (value.in ?? []) as string[];
if (!current.includes(v)) setInValues([...current, v]);
}}
/>
)}
{operator === 'in' && target?.field.type !== 'SELECT' && (
<TextInput
label={t('certReq.condition.values', 'Any of (comma-separated)')}
value={(value.in ?? []).join(', ')}
onChange={(e) =>
setInValues(
e.currentTarget.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
)
}
/>
)}
</Group>
{operator === 'in' && (value.in?.length ?? 0) > 0 && (
<Group gap={4}>
{(value.in ?? []).map((v, i) => (
<Text
key={`${v}-${i}`}
fz="xs"
px={6}
py={2}
bg="var(--mantine-color-gray-1)"
style={{ borderRadius: 4, cursor: 'pointer' }}
onClick={() => setInValues((value.in ?? []).filter((_, idx) => idx !== i).map(String))}
title={t('certReq.condition.removeValue', 'Click to remove')}
>
{String(v)} ×
</Text>
))}
</Group>
)}
{!target && value.field && (
<Text fz="xs" c="dimmed">
{t(
'certReq.condition.unknownField',
'This path is not a field in the current schema yet — it will still be saved as typed.',
)}
</Text>
)}
</Stack>
);
}
const EMPTY_ARM: ConditionArm = { field: '', equals: '' };
/** /**
* Authors one `FieldCondition` (`showWhen` on a section/field, or * Authors one `FieldCondition` (`showWhen` on a section/field, or
* `conditionExpression` on a document requirement). * `conditionExpression` on a document requirement).
@@ -38,6 +232,11 @@ function coerce(raw: string, targetType: string | undefined): string | number |
* SELECT field, the value picker switches to that field's own options * SELECT field, the value picker switches to that field's own options
* instead of free text — the condition can only ever reference an answer * instead of free text — the condition can only ever reference an answer
* that could actually be chosen. * that could actually be chosen.
*
* "Any of these" switches to authoring several single-field conditions whose
* OR is the real condition — needed when the same logical value can live on
* one of several mutually-exclusive fields (e.g. a rank split by
* department, see FieldCondition.anyOf).
*/ */
export function ConditionBuilder({ export function ConditionBuilder({
value, value,
@@ -54,40 +253,35 @@ export function ConditionBuilder({
allowClear?: boolean; allowClear?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const localized = useLocalized();
const active = value !== null; const active = value !== null;
const operator = operatorOf(value ?? undefined) ?? 'equals'; const isAnyOf = Boolean(value?.anyOf);
const target = targets.find((c) => c.path === value?.field); const arms = (value?.anyOf ?? []) as ConditionArm[];
const operators = palette?.conditionOperators ?? ['equals', 'notEquals', 'in', 'isSet'];
function setField(field: string) { function setArm(i: number, arm: ConditionArm) {
onChange({ field, ...(operator === 'isSet' ? { isSet: true } : { equals: '' }) }); const next = arms.slice();
next[i] = arm;
onChange({ anyOf: next });
} }
function setOperator(next: Operator) { function addArm() {
if (!value?.field) return; onChange({ anyOf: [...arms, { ...EMPTY_ARM }] });
const base: ConditionValue = { field: value.field };
if (next === 'isSet') base.isSet = true;
else if (next === 'in') base.in = [];
else if (next === 'notEquals') base.notEquals = '';
else base.equals = '';
onChange(base);
} }
function setValueRaw(raw: string) { function removeArm(i: number) {
if (!value?.field) return; onChange({ anyOf: arms.filter((_, idx) => idx !== i) });
const coerced = coerce(raw, target?.field.type);
if (operator === 'equals') onChange({ field: value.field, equals: coerced });
else if (operator === 'notEquals') onChange({ field: value.field, notEquals: coerced });
} }
function setInValues(raws: string[]) { function toggleAnyOf(next: boolean) {
if (!value?.field) return; if (next) {
onChange({ // Seed the list from whatever single condition already existed, so
field: value.field, // switching modes doesn't discard work in progress.
in: raws.map((r) => coerce(r, target?.field.type)) as (string | number)[], const seed: ConditionArm = value?.field ? (value as ConditionArm) : { ...EMPTY_ARM };
}); onChange({ anyOf: [seed] });
} else {
// Same, in reverse — the first arm becomes the single condition.
onChange((arms[0] as ConditionValue) ?? { field: '', equals: '' });
}
} }
return ( return (
@@ -102,116 +296,58 @@ export function ConditionBuilder({
{active && ( {active && (
<Stack gap="xs" pl={allowClear ? 'md' : 0}> <Stack gap="xs" pl={allowClear ? 'md' : 0}>
<Autocomplete <Switch
label={t('certReq.condition.field', 'Field path')} size="sm"
placeholder="certificate.rank" label={t(
description={t( 'certReq.condition.anyOfEnable',
'certReq.condition.fieldHelp', 'Any of these (the value can live on one of several fields)',
'Dot path into the form, e.g. sectionKey.fieldKey',
)} )}
data={targets.map((c) => c.path)} checked={isAnyOf}
value={value?.field ?? ''} onChange={(e) => toggleAnyOf(e.currentTarget.checked)}
onChange={setField}
/> />
<Group grow align="flex-start"> {isAnyOf ? (
<Select <Stack gap="sm">
label={t('certReq.condition.operator', 'Operator')} {arms.map((arm, i) => (
data={operators.map((op) => ({ value: op, label: op }))} <Paper key={i} withBorder p="sm" radius="sm">
value={operator} <Group justify="space-between" mb="xs">
onChange={(v) => v && setOperator(v as Operator)} <Text fz="xs" fw={600} c="dimmed">
allowDeselect={false} {t('certReq.condition.anyOfArm', 'Condition {{n}}', { n: i + 1 })}
/> </Text>
<ActionIcon
{operator !== 'isSet' && operator !== 'in' && target?.field.type === 'SELECT' && ( variant="subtle"
<Select color="red"
label={t('certReq.condition.value', 'Value')} size="sm"
data={(target.field.options ?? []).map((o) => ({ disabled={arms.length <= 1}
value: o.value, onClick={() => removeArm(i)}
label: localized(o.label) || o.value, >
}))} <IconTrash size={14} />
value={String(value?.equals ?? value?.notEquals ?? '')} </ActionIcon>
onChange={(v) => v !== null && setValueRaw(v)} </Group>
/> <ConditionArmFields
)} value={arm}
onChange={(next) => setArm(i, next)}
{operator !== 'isSet' && operator !== 'in' && target?.field.type !== 'SELECT' && ( targets={targets}
target?.field.type === 'BOOLEAN' ? ( palette={palette}
<Checkbox />
mt="xl" </Paper>
label={t('certReq.condition.value', 'Value')}
checked={Boolean(value?.equals ?? value?.notEquals ?? false)}
onChange={(e) => setValueRaw(String(e.currentTarget.checked))}
/>
) : (
<TextInput
label={t('certReq.condition.value', 'Value')}
type={target?.field.type === 'NUMBER' || target?.field.type === 'MONEY' ? 'number' : 'text'}
value={String(value?.equals ?? value?.notEquals ?? '')}
onChange={(e) => setValueRaw(e.currentTarget.value)}
/>
)
)}
{operator === 'in' && target?.field.type === 'SELECT' && (
<Select
label={t('certReq.condition.values', 'Any of')}
data={(target.field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label) || o.value,
}))}
multiple={undefined}
value={null}
onChange={(v) => {
if (!v) return;
const current = (value?.in ?? []) as string[];
if (!current.includes(v)) setInValues([...current, v]);
}}
/>
)}
{operator === 'in' && target?.field.type !== 'SELECT' && (
<TextInput
label={t('certReq.condition.values', 'Any of (comma-separated)')}
value={(value?.in ?? []).join(', ')}
onChange={(e) =>
setInValues(
e.currentTarget.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
)
}
/>
)}
</Group>
{operator === 'in' && (value?.in?.length ?? 0) > 0 && (
<Group gap={4}>
{(value?.in ?? []).map((v, i) => (
<Text
key={`${v}-${i}`}
fz="xs"
px={6}
py={2}
bg="var(--mantine-color-gray-1)"
style={{ borderRadius: 4, cursor: 'pointer' }}
onClick={() => setInValues((value?.in ?? []).filter((_, idx) => idx !== i).map(String))}
title={t('certReq.condition.removeValue', 'Click to remove')}
>
{String(v)} ×
</Text>
))} ))}
</Group> <Group>
)} <ActionIcon variant="light" onClick={addArm}>
<IconPlus size={16} />
{!target && value?.field && ( </ActionIcon>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
{t( {t('certReq.condition.anyOfAdd', 'Add another field')}
'certReq.condition.unknownField', </Text>
'This path is not a field in the current schema yet — it will still be saved as typed.', </Group>
)} </Stack>
</Text> ) : (
<ConditionArmFields
value={value as ConditionArm}
onChange={(next) => onChange(next)}
targets={targets}
palette={palette}
/>
)} )}
</Stack> </Stack>
)} )}

View File

@@ -94,7 +94,10 @@ export function DocumentRequirementEditorDrawer({
return; return;
} }
if (!draft.name.en?.trim()) return; if (!draft.name.en?.trim()) return;
if (draft.mode === 'CONDITIONAL' && !draft.conditionExpression?.field) { const hasCondition =
Boolean(draft.conditionExpression?.field) ||
Boolean(draft.conditionExpression?.anyOf?.length);
if (draft.mode === 'CONDITIONAL' && !hasCondition) {
setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition')); setKeyError(t('certReq.doc.conditionRequired', 'A conditional requirement needs a condition'));
return; return;
} }

View File

@@ -17,6 +17,7 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import { collectConditionTargets } from '../config/schema-paths'; import { collectConditionTargets } from '../config/schema-paths';
import { useRequirementActions } from '../hooks/useRequirementActions'; import { useRequirementActions } from '../hooks/useRequirementActions';
import { describeCondition } from './ConditionBuilder';
import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer'; import { DocumentRequirementEditorDrawer } from './DocumentRequirementEditorDrawer';
const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL']; const KINDS: ApplicationKind[] = ['NEW', 'RENEWAL'];
@@ -137,13 +138,9 @@ export function DocumentRequirementsTab({ licenseType }: { licenseType: LicenseT
<Text fz="xs" c="dimmed" truncate> <Text fz="xs" c="dimmed" truncate>
key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')} key: {req.key} · {req.maxSizeMb}MB · {req.allowedMimeTypes.join(', ')}
</Text> </Text>
{req.mode === 'CONDITIONAL' && req.conditionExpression?.field && ( {req.mode === 'CONDITIONAL' && req.conditionExpression && (
<Text fz="xs" c="violet"> <Text fz="xs" c="violet">
{t('certReq.doc.when', 'when')} {req.conditionExpression.field}{' '} {t('certReq.doc.when', 'when')} {describeCondition(req.conditionExpression, t)}
{req.conditionExpression.equals !== undefined && `= ${req.conditionExpression.equals}`}
{req.conditionExpression.notEquals !== undefined && `${req.conditionExpression.notEquals}`}
{req.conditionExpression.in !== undefined && `∈ [${req.conditionExpression.in.join(', ')}]`}
{req.conditionExpression.isSet !== undefined && (req.conditionExpression.isSet ? t('certReq.condition.isSet', 'is set') : t('certReq.condition.isNotSet', 'is not set'))}
</Text> </Text>
)} )}
</div> </div>

View File

@@ -558,14 +558,25 @@ export function validateSections(
} }
/** Evaluates a config condition against the current form answers. */ /** Evaluates a config condition against the current form answers. */
interface ConditionLike {
field?: string;
equals?: unknown;
notEquals?: unknown;
in?: (string | number)[];
isSet?: boolean;
/** Holds when ANY listed sub-condition holds — see FieldCondition.anyOf. */
anyOf?: ConditionLike[];
}
export function conditionHolds( export function conditionHolds(
condition: condition: ConditionLike | undefined | null,
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
| undefined
| null,
formData: Record<string, Record<string, unknown>>, formData: Record<string, Record<string, unknown>>,
): boolean { ): boolean {
if (!condition?.field) return true; if (!condition) return true;
if (condition.anyOf) {
return condition.anyOf.some((sub) => conditionHolds(sub, formData));
}
if (!condition.field) return true;
const value = condition.field const value = condition.field
.split('.') .split('.')
.reduce<unknown>( .reduce<unknown>(

View File

@@ -67,11 +67,18 @@ export type FormFieldType =
| "TIN"; | "TIN";
export interface FieldCondition { export interface FieldCondition {
field: string; /** Omitted when `anyOf` is used instead — see below. */
field?: string;
equals?: string | number | boolean; equals?: string | number | boolean;
notEquals?: string | number | boolean; notEquals?: string | number | boolean;
in?: (string | number)[]; in?: (string | number)[];
isSet?: boolean; isSet?: boolean;
/**
* Holds when ANY listed condition holds — for a value that can live on one
* of several mutually-exclusive fields (e.g. a rank split by department).
* `field`/`equals`/etc are ignored when this is present.
*/
anyOf?: FieldCondition[];
} }
export interface FormFieldConfig { export interface FormFieldConfig {