From 7b25040558aed22026701978b00c54ef5c22d112 Mon Sep 17 00:00:00 2001 From: Samuel Male Date: Tue, 19 Nov 2024 20:51:58 +0300 Subject: [PATCH] (feat) (feat) O3-3367 Add support for person attributes --- src/adapters/person-attributes-adapter.ts | 54 ++++++++++++++++ src/api/index.ts | 20 +++++- .../ui-select-extended.component.tsx | 2 +- src/components/renderer/field/fieldLogic.ts | 62 ++++++++++++++++++- .../field/form-field-renderer.component.tsx | 47 +------------- .../person-attribute-datasource.ts | 16 +++++ .../select-concept-answers-datasource.ts | 2 +- src/form-engine.test.tsx | 16 +++++ src/hooks/usePersonAttributes.tsx | 30 +++++++++ .../encounter/encounter-form-processor.ts | 33 +++++++++- .../encounter/encounter-processor-helper.ts | 15 ++++- .../inbuilt-components/control-templates.ts | 6 ++ .../inbuilt-components/inbuiltControls.ts | 2 +- .../inbuilt-components/inbuiltDataSources.ts | 5 ++ .../inbuiltFieldValueAdapters.ts | 5 ++ .../template-component-map.ts | 4 ++ .../default-schema-transformer.ts | 24 +++++++ src/types/schema.ts | 4 ++ 18 files changed, 292 insertions(+), 55 deletions(-) create mode 100644 src/adapters/person-attributes-adapter.ts create mode 100644 src/datasources/person-attribute-datasource.ts create mode 100644 src/hooks/usePersonAttributes.tsx diff --git a/src/adapters/person-attributes-adapter.ts b/src/adapters/person-attributes-adapter.ts new file mode 100644 index 000000000..2331fd7bd --- /dev/null +++ b/src/adapters/person-attributes-adapter.ts @@ -0,0 +1,54 @@ +import { type PersonAttribute, type OpenmrsResource } from '@openmrs/esm-framework'; +import { type FormContextProps } from '../provider/form-provider'; +import { type FormField, type FormFieldValueAdapter, type FormProcessorContextProps } from '../types'; +import { clearSubmission } from '../utils/common-utils'; +import { isEmpty } from '../validators/form-validator'; + +export const PersonAttributesAdapter: FormFieldValueAdapter = { + transformFieldValue: function (field: FormField, value: any, context: FormContextProps) { + clearSubmission(field); + if (field.meta?.previousValue?.value === value || isEmpty(value)) { + return null; + } + field.meta.submission.newValue = { + value: value, + attributeType: field.questionOptions?.attribute?.type, + }; + return field.meta.submission.newValue; + }, + getInitialValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) { + const rendering = field.questionOptions.rendering; + + const personAttributeValue = context?.customDependencies.personAttributes.find( + (attribute: PersonAttribute) => attribute.attributeType.uuid === field.questionOptions.attribute?.type, + )?.value; + if (rendering === 'text') { + if (typeof personAttributeValue === 'string') { + return personAttributeValue; + } else if ( + personAttributeValue && + typeof personAttributeValue === 'object' && + 'display' in personAttributeValue + ) { + return personAttributeValue?.display; + } + } else if (rendering === 'ui-select-extended') { + if (personAttributeValue && typeof personAttributeValue === 'object' && 'uuid' in personAttributeValue) { + return personAttributeValue?.uuid; + } + } + return null; + }, + getPreviousValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) { + return null; + }, + getDisplayValue: function (field: FormField, value: any) { + if (value?.display) { + return value.display; + } + return value; + }, + tearDown: function (): void { + return; + }, +}; diff --git a/src/api/index.ts b/src/api/index.ts index 8124c4df5..3716730cc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,4 @@ -import { fhirBaseUrl, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { fhirBaseUrl, openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework'; import { encounterRepresentation } from '../constants'; import { type OpenmrsForm, type PatientIdentifier, type PatientProgramPayload } from '../types'; import { isUuid } from '../utils/boolean-utils'; @@ -180,3 +180,21 @@ export function savePatientIdentifier(patientIdentifier: PatientIdentifier, pati body: JSON.stringify(patientIdentifier), }); } + +export function savePersonAttribute(personAttribute: PersonAttribute, personUuid: string) { + let url: string; + + if (personAttribute.uuid) { + url = `${restBaseUrl}/person/${personUuid}/attribute/${personAttribute.uuid}`; + } else { + url = `${restBaseUrl}/person/${personUuid}/attribute`; + } + + return openmrsFetch(url, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(personAttribute), + }); +} diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx b/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx index bd8df9e65..0af52c578 100644 --- a/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx +++ b/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx @@ -149,7 +149,7 @@ const UiSelectExtended: React.FC = ({ field, errors, warnin selectedItem={selectedItem} placeholder={isSearchable ? t('search', 'Search') + '...' : null} shouldFilterItem={({ item, inputValue }) => { - if (!inputValue) { + if (!inputValue || items.find((item) => item.uuid == field.value)) { // Carbon's initial call at component mount return true; } diff --git a/src/components/renderer/field/fieldLogic.ts b/src/components/renderer/field/fieldLogic.ts index 8aeda28e2..285a19e94 100644 --- a/src/components/renderer/field/fieldLogic.ts +++ b/src/components/renderer/field/fieldLogic.ts @@ -1,6 +1,6 @@ import { codedTypes } from '../../../constants'; import { type FormContextProps } from '../../../provider/form-provider'; -import { type FormField } from '../../../types'; +import { type FormFieldValidator, type SessionMode, type ValidationResult, type FormField } from '../../../types'; import { isTrue } from '../../../utils/boolean-utils'; import { hasRendering } from '../../../utils/common-utils'; import { evaluateAsyncExpression, evaluateExpression } from '../../../utils/expression-runner'; @@ -65,6 +65,21 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon }, ).then((result) => { setValue(dependent.id, result); + // validate calculated value + const { errors, warnings } = validateFieldValue(dependent, result, context.formFieldValidators, { + formFields, + values, + expressionContext: { patient, mode: sessionMode }, + }); + if (!dependent.meta.submission) { + dependent.meta.submission = {}; + } + dependent.meta.submission.errors = errors; + dependent.meta.submission.warnings = warnings; + if (!errors.length) { + context.formFieldAdapters[dependent.type].transformFieldValue(dependent, result, context); + } + updateFormField(dependent); }); } // evaluate hide @@ -212,3 +227,48 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon setForm(formJson); } } + +export interface ValidatorConfig { + formFields: FormField[]; + values: Record; + expressionContext: { + patient: fhir.Patient; + mode: SessionMode; + }; +} + +export function validateFieldValue( + field: FormField, + value: any, + validators: Record, + context: ValidatorConfig, +): { errors: ValidationResult[]; warnings: ValidationResult[] } { + const errors: ValidationResult[] = []; + const warnings: ValidationResult[] = []; + + if (field.meta.submission?.unspecified) { + return { errors: [], warnings: [] }; + } + + try { + field.validators.forEach((validatorConfig) => { + const results = validators[validatorConfig.type]?.validate?.(field, value, { + ...validatorConfig, + ...context, + }); + if (results) { + results.forEach((result) => { + if (result.resultType === 'error') { + errors.push(result); + } else if (result.resultType === 'warning') { + warnings.push(result); + } + }); + } + }); + } catch (error) { + console.error(error); + } + + return { errors, warnings }; +} diff --git a/src/components/renderer/field/form-field-renderer.component.tsx b/src/components/renderer/field/form-field-renderer.component.tsx index b649fb551..0bdd9580c 100644 --- a/src/components/renderer/field/form-field-renderer.component.tsx +++ b/src/components/renderer/field/form-field-renderer.component.tsx @@ -21,7 +21,7 @@ import { getFieldControlWithFallback, getRegisteredControl } from '../../../regi import styles from './form-field-renderer.scss'; import { isTrue } from '../../../utils/boolean-utils'; import UnspecifiedField from '../../inputs/unspecified/unspecified.component'; -import { handleFieldLogic } from './fieldLogic'; +import { handleFieldLogic, validateFieldValue } from './fieldLogic'; export interface FormFieldRendererProps { fieldId: string; @@ -221,51 +221,6 @@ function ErrorFallback({ error }) { ); } -export interface ValidatorConfig { - formFields: FormField[]; - values: Record; - expressionContext: { - patient: fhir.Patient; - mode: SessionMode; - }; -} - -function validateFieldValue( - field: FormField, - value: any, - validators: Record, - context: ValidatorConfig, -): { errors: ValidationResult[]; warnings: ValidationResult[] } { - const errors: ValidationResult[] = []; - const warnings: ValidationResult[] = []; - - if (field.meta.submission?.unspecified) { - return { errors: [], warnings: [] }; - } - - try { - field.validators.forEach((validatorConfig) => { - const results = validators[validatorConfig.type]?.validate?.(field, value, { - ...validatorConfig, - ...context, - }); - if (results) { - results.forEach((result) => { - if (result.resultType === 'error') { - errors.push(result); - } else if (result.resultType === 'warning') { - warnings.push(result); - } - }); - } - }); - } catch (error) { - console.error(error); - } - - return { errors, warnings }; -} - /** * Determines whether a field can be unspecified */ diff --git a/src/datasources/person-attribute-datasource.ts b/src/datasources/person-attribute-datasource.ts new file mode 100644 index 000000000..a122b76f7 --- /dev/null +++ b/src/datasources/person-attribute-datasource.ts @@ -0,0 +1,16 @@ +import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; +import { BaseOpenMRSDataSource } from './data-source'; + +export class PersonAttributeLocationDataSource extends BaseOpenMRSDataSource { + constructor() { + super(null); + } + + async fetchData(searchTerm: string, config?: Record, uuid?: string): Promise { + const rep = 'v=custom:(uuid,display)'; + const url = `${restBaseUrl}/location?${rep}`; + const { data } = await openmrsFetch(searchTerm ? `${url}&q=${searchTerm}` : url); + + return data?.results; + } +} diff --git a/src/datasources/select-concept-answers-datasource.ts b/src/datasources/select-concept-answers-datasource.ts index 03504ac48..208946550 100644 --- a/src/datasources/select-concept-answers-datasource.ts +++ b/src/datasources/select-concept-answers-datasource.ts @@ -7,7 +7,7 @@ export class SelectConceptAnswersDatasource extends BaseOpenMRSDataSource { } fetchData(searchTerm: string, config?: Record): Promise { - const apiUrl = this.url.replace('conceptUuid', config.referencedValue || config.concept); + const apiUrl = this.url.replace('conceptUuid', config.concept || config.referencedValue); return openmrsFetch(apiUrl).then(({ data }) => { return data['setMembers'].length ? data['setMembers'] : data['answers']; }); diff --git a/src/form-engine.test.tsx b/src/form-engine.test.tsx index fa6de7263..4e60fc2d0 100644 --- a/src/form-engine.test.tsx +++ b/src/form-engine.test.tsx @@ -681,6 +681,8 @@ describe('Form engine component', () => { describe('Calculated values', () => { it('should evaluate BMI', async () => { + const saveEncounterMock = jest.spyOn(api, 'saveEncounter'); + await act(async () => renderForm(null, bmiForm)); const bmiField = screen.getByRole('textbox', { name: /bmi/i }); @@ -694,9 +696,17 @@ describe('Form engine component', () => { expect(heightField).toHaveValue(150); expect(weightField).toHaveValue(50); expect(bmiField).toHaveValue('22.2'); + + await user.click(screen.getByRole('button', { name: /save/i })); + + const encounter = saveEncounterMock.mock.calls[0][1]; + expect(encounter.obs.length).toEqual(3); + expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bmi').value).toBe(22.2); }); it('should evaluate BSA', async () => { + const saveEncounterMock = jest.spyOn(api, 'saveEncounter'); + await act(async () => renderForm(null, bsaForm)); const bsaField = screen.getByRole('textbox', { name: /bsa/i }); @@ -710,6 +720,12 @@ describe('Form engine component', () => { expect(heightField).toHaveValue(190.5); expect(weightField).toHaveValue(95); expect(bsaField).toHaveValue('2.24'); + + await user.click(screen.getByRole('button', { name: /save/i })); + + const encounter = saveEncounterMock.mock.calls[0][1]; + expect(encounter.obs.length).toEqual(3); + expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bsa').value).toBe(2.24); }); it('should evaluate EDD', async () => { diff --git a/src/hooks/usePersonAttributes.tsx b/src/hooks/usePersonAttributes.tsx new file mode 100644 index 000000000..b65fae98d --- /dev/null +++ b/src/hooks/usePersonAttributes.tsx @@ -0,0 +1,30 @@ +import { openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework'; +import { useEffect, useState } from 'react'; + +export const usePersonAttributes = (patientUuid: string) => { + const [personAttributes, setPersonAttributes] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (patientUuid) { + openmrsFetch(`${restBaseUrl}/patient/${patientUuid}?v=custom:(attributes)`) + .then((response) => { + setPersonAttributes(response?.data?.attributes); + setIsLoading(false); + }) + .catch((error) => { + setError(error); + setIsLoading(false); + }); + } else { + setIsLoading(false); + } + }, [patientUuid]); + + return { + personAttributes, + error, + isLoading: isLoading, + }; +}; diff --git a/src/processors/encounter/encounter-form-processor.ts b/src/processors/encounter/encounter-form-processor.ts index 2ff47387b..e4bc56b92 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -19,9 +19,11 @@ import { prepareEncounter, preparePatientIdentifiers, preparePatientPrograms, + preparePersonAttributes, saveAttachments, savePatientIdentifiers, savePatientPrograms, + savePersonAttributes, } from './encounter-processor-helper'; import { type OpenmrsResource, showSnackbar, translateFrom } from '@openmrs/esm-framework'; import { moduleName } from '../../globals'; @@ -31,6 +33,7 @@ import { useEncounterRole } from '../../hooks/useEncounterRole'; import { evaluateAsyncExpression, type FormNode } from '../../utils/expression-runner'; import { hasRendering } from '../../utils/common-utils'; import { extractObsValueAndDisplay } from '../../utils/form-helper'; +import { usePersonAttributes } from '../../hooks/usePersonAttributes'; function useCustomHooks(context: Partial) { const [isLoading, setIsLoading] = useState(true); @@ -40,13 +43,14 @@ function useCustomHooks(context: Partial) { context.patient?.id, context.formJson, ); + const { isLoading: isLoadingPersonAttributes, personAttributes } = usePersonAttributes(context.patient?.id); useEffect(() => { - setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole); - }, [isLoadingPatientPrograms, isLoadingEncounter, isLoadingEncounterRole]); + setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole || isLoadingPersonAttributes); + }, [isLoadingPatientPrograms, isLoadingEncounter, isLoadingEncounterRole, isLoadingPersonAttributes]); return { - data: { encounter, patientPrograms, encounterRole }, + data: { encounter, patientPrograms, encounterRole, personAttributes }, isLoading, error: null, updateContext: (setContext: React.Dispatch>) => { @@ -59,6 +63,7 @@ function useCustomHooks(context: Partial) { ...context.customDependencies, patientPrograms: patientPrograms, defaultEncounterRole: encounterRole, + personAttributes: personAttributes, }, }; }); @@ -79,6 +84,7 @@ const contextInitializableTypes = [ 'patientIdentifier', 'encounterRole', 'programState', + 'personAttributes', ]; export class EncounterFormProcessor extends FormProcessor { @@ -162,6 +168,27 @@ export class EncounterFormProcessor extends FormProcessor { }); } + // save person attributes + try { + const personattributes = preparePersonAttributes(context.formFields, context.location?.uuid); + const savedPrograms = await savePersonAttributes(context.patient, personattributes); + if (savedPrograms?.length) { + showSnackbar({ + title: translateFn('personAttributesSaved', 'Person attribute(s) saved successfully'), + kind: 'success', + isLowContrast: true, + }); + } + } catch (error) { + const errorMessages = extractErrorMessagesFromResponse(error); + return Promise.reject({ + title: translateFn('errorSavingPersonAttributes', 'Error saving person attributes'), + description: errorMessages.join(', '), + kind: 'error', + critical: true, + }); + } + // save encounter try { const { data: savedEncounter } = await saveEncounter(abortController, encounter, encounter.uuid); diff --git a/src/processors/encounter/encounter-processor-helper.ts b/src/processors/encounter/encounter-processor-helper.ts index 852187109..89e01f905 100644 --- a/src/processors/encounter/encounter-processor-helper.ts +++ b/src/processors/encounter/encounter-processor-helper.ts @@ -7,7 +7,7 @@ import { type PatientProgramPayload, type FormProcessorContextProps, } from '../../types'; -import { saveAttachment, savePatientIdentifier, saveProgramEnrollment } from '../../api'; +import { saveAttachment, savePatientIdentifier, savePersonAttribute, saveProgramEnrollment } from '../../api'; import { hasRendering, hasSubmission } from '../../utils/common-utils'; import dayjs from 'dayjs'; import { voidObs, constructObs, assignedObsIds } from '../../adapters/obs-adapter'; @@ -16,6 +16,7 @@ import { ConceptTrue } from '../../constants'; import { DefaultValueValidator } from '../../validators/default-value-validator'; import { cloneRepeatField } from '../../components/repeat/helpers'; import { assignedOrderIds } from '../../adapters/orders-adapter'; +import { type PersonAttribute } from '@openmrs/esm-framework'; export function prepareEncounter( context: FormContextProps, @@ -152,6 +153,12 @@ export function saveAttachments(fields: FormField[], encounter: OpenmrsEncounter }); } +export function savePersonAttributes(patient: fhir.Patient, attributes: PersonAttribute[]) { + return attributes.map((personAttribute) => { + return savePersonAttribute(personAttribute, patient.id); + }); +} + export function getMutableSessionProps(context: FormContextProps) { const { formFields, @@ -318,3 +325,9 @@ export async function hydrateRepeatField( }), ).then((results) => results.flat()); } + +export function preparePersonAttributes(fields: FormField[], encounterLocation: string): PersonAttribute[] { + return fields + .filter((field) => field.type === 'personAttribute' && hasSubmission(field)) + .map((field) => field.meta.submission.newValue); +} diff --git a/src/registry/inbuilt-components/control-templates.ts b/src/registry/inbuilt-components/control-templates.ts index 0ea36c84d..55b1f34c5 100644 --- a/src/registry/inbuilt-components/control-templates.ts +++ b/src/registry/inbuilt-components/control-templates.ts @@ -50,6 +50,12 @@ export const controlTemplates: Array = [ }, }, }, + { + name: 'person-attribute-location', + datasource: { + name: 'person_attribute_location_datasource', + }, + }, ]; export const getControlTemplate = (name: string) => { diff --git a/src/registry/inbuilt-components/inbuiltControls.ts b/src/registry/inbuilt-components/inbuiltControls.ts index 4e4a80938..fa005a6ad 100644 --- a/src/registry/inbuilt-components/inbuiltControls.ts +++ b/src/registry/inbuilt-components/inbuiltControls.ts @@ -94,6 +94,6 @@ export const inbuiltControls: Array ({ name: template.name, - component: templateToComponentMap.find((component) => component.name === template.name).baseControlComponent, + component: templateToComponentMap.find((component) => component.name === template.name)?.baseControlComponent, })), ]; diff --git a/src/registry/inbuilt-components/inbuiltDataSources.ts b/src/registry/inbuilt-components/inbuiltDataSources.ts index 0a1d92492..188107ee0 100644 --- a/src/registry/inbuilt-components/inbuiltDataSources.ts +++ b/src/registry/inbuilt-components/inbuiltDataSources.ts @@ -5,6 +5,7 @@ import { LocationDataSource } from '../../datasources/location-data-source'; import { ProviderDataSource } from '../../datasources/provider-datasource'; import { SelectConceptAnswersDatasource } from '../../datasources/select-concept-answers-datasource'; import { EncounterRoleDataSource } from '../../datasources/encounter-role-datasource'; +import { PersonAttributeLocationDataSource } from '../../datasources/person-attribute-datasource'; /** * @internal @@ -34,6 +35,10 @@ export const inbuiltDataSources: Array>> = [ name: 'encounter_role_datasource', component: new EncounterRoleDataSource(), }, + { + name: 'person_attribute_location_datasource', + component: new PersonAttributeLocationDataSource(), + }, ]; export const validateInbuiltDatasource = (name: string) => { diff --git a/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts b/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts index 9a4c3d850..c4d62bd37 100644 --- a/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts +++ b/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts @@ -11,6 +11,7 @@ import { OrdersAdapter } from '../../adapters/orders-adapter'; import { PatientIdentifierAdapter } from '../../adapters/patient-identifier-adapter'; import { ProgramStateAdapter } from '../../adapters/program-state-adapter'; import { type FormFieldValueAdapter } from '../../types'; +import { PersonAttributesAdapter } from '../../adapters/person-attributes-adapter'; export const inbuiltFieldValueAdapters: RegistryItem[] = [ { @@ -61,4 +62,8 @@ export const inbuiltFieldValueAdapters: RegistryItem[] = type: 'patientIdentifier', component: PatientIdentifierAdapter, }, + { + type: 'personAttribute', + component: PersonAttributesAdapter, + }, ]; diff --git a/src/registry/inbuilt-components/template-component-map.ts b/src/registry/inbuilt-components/template-component-map.ts index ad0c9064d..8e6b76766 100644 --- a/src/registry/inbuilt-components/template-component-map.ts +++ b/src/registry/inbuilt-components/template-component-map.ts @@ -25,4 +25,8 @@ export const templateToComponentMap = [ name: 'encounter-role', baseControlComponent: UiSelectExtended, }, + { + name: 'person_attribute_location_datasource', + baseControlComponent: UiSelectExtended, + }, ]; diff --git a/src/transformers/default-schema-transformer.ts b/src/transformers/default-schema-transformer.ts index 0433ae339..8332338ed 100644 --- a/src/transformers/default-schema-transformer.ts +++ b/src/transformers/default-schema-transformer.ts @@ -133,6 +133,9 @@ function transformByType(question: FormField) { ? 'date' : question.questionOptions.rendering; break; + case 'personAttribute': + handlePersonAttributeType(question); + break; } } @@ -259,3 +262,24 @@ function handleQuestionsWithObsComments(sectionQuestions: Array): Arr return augmentedQuestions; } + +function handlePersonAttributeType(question: FormField) { + if (question.questionOptions.rendering !== 'text') { + question.questionOptions.rendering === 'ui-select-extended'; + } + + if (question.questionOptions?.attribute?.format === 'location') { + question.questionOptions.datasource = { + name: 'person_attribute_location_datasource', + }; + } else if (question.questionOptions?.attribute?.format === 'concept') { + question.questionOptions.datasource = { + name: 'select_concept_answers_datasource', + config: { + concept: question.questionOptions?.concept, + }, + }; + } else if (question.questionOptions?.attribute?.format === 'string') { + question.questionOptions.rendering = 'text'; + } +} diff --git a/src/types/schema.ts b/src/types/schema.ts index 424f15bad..ea849d6b4 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -182,6 +182,10 @@ export interface FormQuestionOptions { comment?: string; orientation?: 'vertical' | 'horizontal'; shownCommentOptions?: { validators?: Array>; hide?: { hideWhenExpression: string } }; + attribute?: { + type?: string; + format?: string; + }; } export interface QuestionAnswerOption {