diff --git a/libs/application/core/src/lib/fieldBuilders.ts b/libs/application/core/src/lib/fieldBuilders.ts index 2f1a86f6a03c..dabe437861a0 100644 --- a/libs/application/core/src/lib/fieldBuilders.ts +++ b/libs/application/core/src/lib/fieldBuilders.ts @@ -46,6 +46,7 @@ import { SliderField, MaybeWithApplication, MaybeWithApplicationAndFieldAndLocale, + FieldsRepeaterField, } from '@island.is/application/types' import { Locale } from '@island.is/shared/types' import { Colors } from '@island.is/island-ui/theme' @@ -867,6 +868,48 @@ export const buildTableRepeaterField = ( } } +export const buildFieldsRepeaterField = ( + data: Omit, +): FieldsRepeaterField => { + const { + fields, + table, + title, + titleVariant, + formTitle, + formTitleVariant, + formTitleNumbering, + marginTop, + marginBottom, + removeItemButtonText, + addItemButtonText, + saveItemButtonText, + minRows, + maxRows, + } = data + + return { + ...extractCommonFields(data), + children: undefined, + type: FieldTypes.FIELDS_REPEATER, + component: FieldComponents.FIELDS_REPEATER, + fields, + table, + title, + titleVariant, + formTitle, + formTitleVariant, + formTitleNumbering, + marginTop, + marginBottom, + removeItemButtonText, + addItemButtonText, + saveItemButtonText, + minRows, + maxRows, + } +} + export const buildStaticTableField = ( data: Omit< StaticTableField, diff --git a/libs/application/core/src/lib/messages.ts b/libs/application/core/src/lib/messages.ts index 3190a3542b1a..65cb2f5cdd67 100644 --- a/libs/application/core/src/lib/messages.ts +++ b/libs/application/core/src/lib/messages.ts @@ -41,6 +41,11 @@ export const coreMessages = defineMessages({ defaultMessage: 'Bæta við', description: 'Add button', }, + buttonRemove: { + id: 'application.system:button.remove', + defaultMessage: 'Fjarlægja', + description: 'Remove button', + }, buttonCancel: { id: 'application.system:button.cancel', defaultMessage: 'Hætta við', diff --git a/libs/application/types/src/lib/Fields.ts b/libs/application/types/src/lib/Fields.ts index 9ea37be69132..c6763dbb9141 100644 --- a/libs/application/types/src/lib/Fields.ts +++ b/libs/application/types/src/lib/Fields.ts @@ -64,7 +64,7 @@ export type TagVariant = | 'mint' | 'disabled' -export type TableRepeaterFields = +export type RepeaterFields = | 'input' | 'select' | 'radio' @@ -82,8 +82,8 @@ type TableRepeaterOptions = activeField?: Record, ) => RepeaterOption[] | []) -export type TableRepeaterItem = { - component: TableRepeaterFields +export type RepeaterItem = { + component: RepeaterFields /** * Defaults to true */ @@ -253,6 +253,7 @@ export enum FieldTypes { NATIONAL_ID_WITH_NAME = 'NATIONAL_ID_WITH_NAME', ACTION_CARD_LIST = 'ACTION_CARD_LIST', TABLE_REPEATER = 'TABLE_REPEATER', + FIELDS_REPEATER = 'FIELDS_REPEATER', HIDDEN_INPUT = 'HIDDEN_INPUT', HIDDEN_INPUT_WITH_WATCHED_VALUE = 'HIDDEN_INPUT_WITH_WATCHED_VALUE', FIND_VEHICLE = 'FIND_VEHICLE', @@ -289,6 +290,7 @@ export enum FieldComponents { NATIONAL_ID_WITH_NAME = 'NationalIdWithNameFormField', ACTION_CARD_LIST = 'ActionCardListFormField', TABLE_REPEATER = 'TableRepeaterFormField', + FIELDS_REPEATER = 'FieldsRepeaterFormField', HIDDEN_INPUT = 'HiddenInputFormField', FIND_VEHICLE = 'FindVehicleFormField', VEHICLE_RADIO = 'VehicleRadioFormField', @@ -639,7 +641,7 @@ export type TableRepeaterField = BaseField & { marginTop?: ResponsiveProp marginBottom?: ResponsiveProp titleVariant?: TitleVariants - fields: Record + fields: Record /** * Maximum rows that can be added to the table. * When the maximum is reached, the button to add a new row is disabled. @@ -659,6 +661,41 @@ export type TableRepeaterField = BaseField & { format?: Record string | StaticText> } } + +export type FieldsRepeaterField = BaseField & { + readonly type: FieldTypes.FIELDS_REPEATER + component: FieldComponents.FIELDS_REPEATER + titleVariant?: TitleVariants + formTitle?: StaticText + formTitleVariant?: TitleVariants + formTitleNumbering?: 'prefix' | 'suffix' | 'none' + removeItemButtonText?: StaticText + addItemButtonText?: StaticText + saveItemButtonText?: StaticText + marginTop?: ResponsiveProp + marginBottom?: ResponsiveProp + fields: Record + /** + * Maximum rows that can be added to the table. + * When the maximum is reached, the button to add a new row is disabled. + */ + minRows?: number + maxRows?: number + table?: { + /** + * List of strings to render, + * if not provided it will be auto generated from the fields + */ + header?: StaticText[] + /** + * List of field id's to render, + * if not provided it will be auto generated from the fields + */ + rows?: string[] + format?: Record string | StaticText> + } +} + export interface FindVehicleField extends InputField { readonly type: FieldTypes.FIND_VEHICLE component: FieldComponents.FIND_VEHICLE @@ -786,6 +823,7 @@ export type Field = | NationalIdWithNameField | ActionCardListField | TableRepeaterField + | FieldsRepeaterField | HiddenInputWithWatchedValueField | HiddenInputField | FindVehicleField diff --git a/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.stories.mdx b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.stories.mdx new file mode 100644 index 000000000000..081dc5c3a1dc --- /dev/null +++ b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.stories.mdx @@ -0,0 +1,128 @@ +import { + Meta, + Story, + Canvas, + ArgsTable, + Description, + Source, +} from '@storybook/addon-docs' +import { dedent } from 'ts-dedent' + +import { FieldsRepeaterFormField } from './FieldsRepeaterFormField' + +export const createMockApplication = (data = {}) => ({ + id: '123', + assignees: [], + state: data.state || 'draft', + applicant: '111111-3000', + typeId: data.typeId || 'ExampleForm', + modified: new Date(), + created: new Date(), + attachments: {}, + answers: data.answers || {}, + externalData: data.externalData || {}, +}) + + + +# TableRepeaterFormField + +### Usage in a template + +You can create a FieldsRepeaterFormField using the following function `FieldsTableRepeaterField`. +Validation should be done via zod schema. + + + +The previous configuration object will result in the following component: + + + + value.replace(/^(.{3})/, '$1-'), + }, + }, + }} + /> + + + +You can also use this field into a custom component by using `` with the configuration object defined above. + +# Props + +## FieldsRepeaterFormField + + diff --git a/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.tsx b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.tsx new file mode 100644 index 000000000000..9edc6dd1890e --- /dev/null +++ b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterFormField.tsx @@ -0,0 +1,205 @@ +import { Fragment, useEffect, useState } from 'react' +import { + coreMessages, + formatText, + formatTextWithLocale, + getValueViaPath, +} from '@island.is/application/core' +import { + FieldBaseProps, + FieldsRepeaterField, +} from '@island.is/application/types' +import { + AlertMessage, + Box, + Button, + GridRow, + Stack, + Text, +} from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { FieldDescription } from '@island.is/shared/form-fields' +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form' +import { Item } from './FieldsRepeaterItem' +import { Locale } from '@island.is/shared/types' +import isEqual from 'lodash/isEqual' + +interface Props extends FieldBaseProps { + field: FieldsRepeaterField +} + +export const FieldsRepeaterFormField = ({ + application, + field: data, + showFieldName, + error, +}: Props) => { + const { + id, + fields: rawItems, + description, + marginTop = 6, + marginBottom, + title, + titleVariant = 'h2', + formTitle, + formTitleVariant = 'h4', + formTitleNumbering = 'suffix', + removeItemButtonText = coreMessages.buttonRemove, + addItemButtonText = coreMessages.buttonAdd, + minRows = 1, + maxRows, + } = data + + const { control, getValues, setValue } = useFormContext() + const answers = getValues() + const numberOfItemsInAnswers = getValueViaPath>( + answers, + id, + )?.length + const [numberOfItems, setNumberOfItems] = useState( + Math.max(numberOfItemsInAnswers ?? 0, minRows), + ) + const [updatedApplication, setUpdatedApplication] = useState(application) + + useEffect(() => { + if (!isEqual(application, updatedApplication)) { + setUpdatedApplication({ + ...application, + answers: { ...answers }, + }) + } + }, [answers]) + + const items = Object.keys(rawItems).map((key) => ({ + id: key, + ...rawItems[key], + })) + + const { formatMessage, lang: locale } = useLocale() + + const { fields, remove } = useFieldArray({ + control: control, + name: id, + }) + + const values = useWatch({ name: data.id, control: control }) + + const handleNewItem = () => { + setNumberOfItems(numberOfItems + 1) + } + + const handleRemoveItem = () => { + if (numberOfItems > (numberOfItemsInAnswers || 0)) { + setNumberOfItems(numberOfItems - 1) + } else if (numberOfItems === numberOfItemsInAnswers) { + setValue(id, answers[id].slice(0, -1)) + setNumberOfItems(numberOfItems - 1) + } else if ( + numberOfItemsInAnswers && + numberOfItems < numberOfItemsInAnswers + ) { + const difference = numberOfItems - numberOfItemsInAnswers + setValue(id, answers[id].slice(0, difference)) + setNumberOfItems(numberOfItems) + } + + remove(numberOfItems - 1) + } + + const repeaterFields = (index: number) => + items.map((item) => ( + + )) + + return ( + + {showFieldName && ( + + {formatTextWithLocale( + title, + application, + locale as Locale, + formatMessage, + )} + + )} + {description && ( + + )} + + + + {Array.from({ length: numberOfItems }).map((_i, i) => ( + + {(formTitleNumbering !== 'none' || formTitle) && ( + + + {formTitleNumbering === 'prefix' ? `${i + 1}. ` : ''} + {formTitle && + formatTextWithLocale( + formTitle, + application, + locale as Locale, + formatMessage, + )} + {formTitleNumbering === 'suffix' ? ` ${i + 1}` : ''} + + + )} + {repeaterFields(i)} + + ))} + + + {numberOfItems > minRows && ( + + + + )} + + + + {error && typeof error === 'string' && fields.length === 0 && ( + + + + )} + + + ) +} diff --git a/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterItem.tsx b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterItem.tsx new file mode 100644 index 000000000000..4fd041e97550 --- /dev/null +++ b/libs/application/ui-fields/src/lib/FieldsRepeaterFormField/FieldsRepeaterItem.tsx @@ -0,0 +1,223 @@ +import { formatText, getValueViaPath } from '@island.is/application/core' +import { Application, RepeaterItem } from '@island.is/application/types' +import { GridColumn, Text } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { useEffect, useRef } from 'react' +import { useFormContext } from 'react-hook-form' +import isEqual from 'lodash/isEqual' +import { + CheckboxController, + DatePickerController, + InputController, + RadioController, + SelectController, + PhoneInputController, +} from '@island.is/shared/form-fields' +import { NationalIdWithName } from '@island.is/application/ui-components' + +interface ItemFieldProps { + application: Application + error?: string + item: RepeaterItem & { id: string } + dataId: string + index: number + values: Array> +} + +const componentMapper = { + input: InputController, + select: SelectController, + checkbox: CheckboxController, + date: DatePickerController, + radio: RadioController, + nationalIdWithName: NationalIdWithName, + phone: PhoneInputController, +} + +export const Item = ({ + application, + error, + item, + dataId, + index, + values, +}: ItemFieldProps) => { + const { formatMessage } = useLocale() + const { setValue, control, clearErrors } = useFormContext() + const prevWatchedValuesRef = useRef() + + const { + component, + id: itemId, + backgroundColor = 'blue', + label = '', + placeholder = '', + options, + width = 'full', + condition, + readonly = false, + disabled = false, + updateValueObj, + defaultValue, + ...props + } = item + const isHalfColumn = component !== 'radio' && width === 'half' + const isThirdColumn = component !== 'radio' && width === 'third' + const span = isHalfColumn ? '1/2' : isThirdColumn ? '1/3' : '1/1' + const Component = componentMapper[component] + const id = `${dataId}[${index}].${itemId}` + const activeValues = index >= 0 && values ? values[index] : undefined + + let watchedValues: string | (string | undefined)[] | undefined + if (updateValueObj) { + const watchedValuesId = + typeof updateValueObj.watchValues === 'function' + ? updateValueObj.watchValues(activeValues) + : updateValueObj.watchValues + + if (watchedValuesId) { + if (Array.isArray(watchedValuesId)) { + watchedValues = watchedValuesId.map((value) => { + return activeValues?.[`${value}`] + }) + } else { + watchedValues = activeValues?.[`${watchedValuesId}`] + } + } + } + + useEffect(() => { + // We need to deep compare the watched values to avoid unnecessary re-renders + if ( + watchedValues && + !isEqual(prevWatchedValuesRef.current, watchedValues) + ) { + prevWatchedValuesRef.current = watchedValues + if ( + updateValueObj && + watchedValues && + (Array.isArray(watchedValues) + ? !watchedValues.every((value) => value === undefined) + : true) + ) { + const finalValue = updateValueObj.valueModifier( + application, + activeValues, + ) + setValue(id, finalValue) + } + } + }, [watchedValues, updateValueObj, activeValues, setValue, id]) + + const getFieldError = (id: string) => { + /** + * Errors that occur in a field-array have incorrect typing + * This hack is needed to get the correct type + */ + const errorList = error as unknown as Record[] | undefined + const errors = errorList?.[index] + return errors?.[id] + } + + const getDefaultValue = ( + item: RepeaterItem, + application: Application, + activeField?: Record, + ) => { + const { defaultValue } = item + + if (defaultValue === undefined) { + return undefined + } + + return defaultValue(application, activeField) + } + + let translatedOptions: any = [] + if (typeof options === 'function') { + translatedOptions = options(application, activeValues) + } else { + translatedOptions = + options?.map((option) => ({ + ...option, + label: formatText(option.label, application, formatMessage), + ...(option.tooltip && { + tooltip: formatText(option.tooltip, application, formatMessage), + }), + })) ?? [] + } + + let Readonly: boolean | undefined + if (typeof readonly === 'function') { + Readonly = readonly(application, activeValues) + } else { + Readonly = readonly + } + + let Disabled: boolean | undefined + if (typeof disabled === 'function') { + Disabled = disabled(application, activeValues) + } else { + Disabled = disabled + } + + let DefaultValue: any + if (component === 'input') { + DefaultValue = getDefaultValue(item, application, activeValues) + } + if (component === 'select') { + DefaultValue = + getValueViaPath(application.answers, id) ?? + getDefaultValue(item, application, activeValues) + } + if (component === 'radio') { + DefaultValue = + (getValueViaPath(application.answers, id) as string[]) ?? + getDefaultValue(item, application, activeValues) + } + if (component === 'checkbox') { + DefaultValue = + (getValueViaPath(application.answers, id) as string[]) ?? + getDefaultValue(item, application, activeValues) + } + if (component === 'date') { + DefaultValue = + (getValueViaPath(application.answers, id) as string) ?? + getDefaultValue(item, application, activeValues) + } + + if (condition && !condition(application, activeValues)) { + return null + } + + return ( + + {component === 'radio' && label && ( + + {formatText(label, application, formatMessage)} + + )} + { + if (error) { + clearErrors(id) + } + }} + application={application} + defaultValue={DefaultValue} + {...props} + /> + + ) +} diff --git a/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx b/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx index dc5c01ae8309..5388065f176f 100644 --- a/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx +++ b/libs/application/ui-fields/src/lib/TableRepeaterFormField/TableRepeaterItem.tsx @@ -1,5 +1,5 @@ import { formatText, getValueViaPath } from '@island.is/application/core' -import { Application, TableRepeaterItem } from '@island.is/application/types' +import { Application, RepeaterItem } from '@island.is/application/types' import { GridColumn, Text } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' import { useEffect, useRef } from 'react' @@ -18,7 +18,7 @@ import { NationalIdWithName } from '@island.is/application/ui-components' interface ItemFieldProps { application: Application error?: string - item: TableRepeaterItem & { id: string } + item: RepeaterItem & { id: string } dataId: string activeIndex: number values: Array> @@ -121,7 +121,7 @@ export const Item = ({ } const getDefaultValue = ( - item: TableRepeaterItem, + item: RepeaterItem, application: Application, activeField?: Record, ) => { diff --git a/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts b/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts index f1b0c79ee077..7843b128e023 100644 --- a/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts +++ b/libs/application/ui-fields/src/lib/TableRepeaterFormField/utils.ts @@ -1,9 +1,9 @@ +import { RepeaterItem } from '@island.is/application/types' import { coreMessages } from '@island.is/application/core' -import { TableRepeaterItem } from '@island.is/application/types' type Item = { id: string -} & TableRepeaterItem +} & RepeaterItem export type Value = { [key: string]: T } @@ -42,7 +42,7 @@ const handleNationalIdWithNameItem = ( return newValues } -export const buildDefaultTableHeader = (items: Array) => +export const buildDefaultTableHeader = (items: Array) => items .map((item) => // nationalIdWithName is a special case where the value is an object of name and nationalId @@ -53,7 +53,7 @@ export const buildDefaultTableHeader = (items: Array) => .flat(2) export const buildDefaultTableRows = ( - items: Array, + items: Array, ) => items .map((item) => diff --git a/libs/application/ui-fields/src/lib/index.ts b/libs/application/ui-fields/src/lib/index.ts index c3e3f9724309..5ca103c7e046 100644 --- a/libs/application/ui-fields/src/lib/index.ts +++ b/libs/application/ui-fields/src/lib/index.ts @@ -24,6 +24,7 @@ export { NationalIdWithNameFormField } from './NationalIdWithNameFormField/Natio export { HiddenInputFormField } from './HiddenInputFormField/HiddenInputFormField' export { ActionCardListFormField } from './ActionCardListFormField/ActionCardListFormField' export { TableRepeaterFormField } from './TableRepeaterFormField/TableRepeaterFormField' +export { FieldsRepeaterFormField } from './FieldsRepeaterFormField/FieldsRepeaterFormField' export { FindVehicleFormField } from './FindVehicleFormField/FindVehicleFormField' export { VehicleRadioFormField } from './VehicleRadioFormField/VehicleRadioFormField' export { StaticTableFormField } from './StaticTableFormField/StaticTableFormField'