@evoke-platform/ui-components 1.8.2-testing.0 → 1.9.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/published/components/custom/Form/FormComponents/ObjectComponent/ObjectPropertyInput.js +49 -4
- package/dist/published/components/custom/FormV2/FormRenderer.js +19 -16
- package/dist/published/components/custom/FormV2/FormRendererContainer.js +16 -4
- package/dist/published/components/custom/FormV2/components/AccordionSections.js +30 -29
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +1 -1
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +1 -2
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +16 -7
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/ObjectPropertyInput.js +88 -39
- package/dist/published/components/custom/FormV2/components/FormSections.js +34 -3
- package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +10 -29
- package/dist/published/components/custom/FormV2/components/ValidationFiles/Validation.js +2 -2
- package/dist/published/components/custom/FormV2/components/types.d.ts +9 -1
- package/dist/published/components/custom/FormV2/components/utils.d.ts +18 -2
- package/dist/published/components/custom/FormV2/components/utils.js +163 -1
- package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +211 -2
- package/dist/published/components/custom/FormV2/tests/test-data.d.ts +9 -0
- package/dist/published/components/custom/FormV2/tests/test-data.js +134 -0
- package/dist/published/stories/Accordion.stories.d.ts +36 -4
- package/dist/published/stories/Alert.stories.d.ts +2 -4
- package/dist/published/stories/AlertTitle.stories.d.ts +2 -4
- package/dist/published/stories/Appbar.stories.d.ts +10 -3
- package/dist/published/stories/Autocomplete.stories.d.ts +22 -4
- package/dist/published/stories/Avatar.stories.d.ts +16 -3
- package/dist/published/stories/Backdrop.stories.d.ts +10 -4
- package/dist/published/stories/Badge.stories.d.ts +10 -4
- package/dist/published/stories/Box.stories.d.ts +2 -3
- package/dist/published/stories/Breadcrumbs.stories.d.ts +10 -4
- package/dist/published/stories/BuilderGrid.stories.d.ts +54 -5
- package/dist/published/stories/Button.stories.d.ts +10 -4
- package/dist/published/stories/ButtonGroup.stories.d.ts +10 -4
- package/dist/published/stories/Card.stories.d.ts +10 -4
- package/dist/published/stories/Checkbox.stories.d.ts +2 -4
- package/dist/published/stories/Chip.stories.d.ts +10 -4
- package/dist/published/stories/CircularProgress.stories.d.ts +2 -4
- package/dist/published/stories/Collapse.stories.d.ts +2 -4
- package/dist/published/stories/Container.stories.d.ts +10 -4
- package/dist/published/stories/CriteriaBuilder.stories.d.ts +6 -8
- package/dist/published/stories/DataGrid.stories.d.ts +40 -4
- package/dist/published/stories/DatePicker.stories.d.ts +7 -4
- package/dist/published/stories/Dialog.stories.d.ts +2 -4
- package/dist/published/stories/Divider.stories.d.ts +10 -4
- package/dist/published/stories/Drawer.stories.d.ts +2 -4
- package/dist/published/stories/Form.stories.d.ts +4 -5
- package/dist/published/stories/FormControl.stories.d.ts +10 -4
- package/dist/published/stories/FormControlLabel.stories.d.ts +2 -4
- package/dist/published/stories/FormField.stories.d.ts +11 -13
- package/dist/published/stories/FormGroup.stories.d.ts +2 -4
- package/dist/published/stories/FormHelperText.stories.d.ts +10 -4
- package/dist/published/stories/FormLabel.stories.d.ts +10 -4
- package/dist/published/stories/FormRenderer.stories.d.ts +69 -6
- package/dist/published/stories/FormRendererContainer.stories.d.ts +111 -6
- package/dist/published/stories/FormRendererContainer.stories.js +5 -0
- package/dist/published/stories/FormRendererData.d.ts +7 -0
- package/dist/published/stories/FormRendererData.js +172 -1
- package/dist/published/stories/Grid.stories.d.ts +10 -4
- package/dist/published/stories/HistoryLog.stories.d.ts +2 -4
- package/dist/published/stories/IconButton.stories.d.ts +10 -4
- package/dist/published/stories/LinearProgress.stories.d.ts +2 -4
- package/dist/published/stories/Link.stories.d.ts +10 -4
- package/dist/published/stories/List.stories.d.ts +10 -4
- package/dist/published/stories/Menu.stories.d.ts +2 -4
- package/dist/published/stories/MenuBar.stories.d.ts +3 -4
- package/dist/published/stories/MultiSelect.stories.d.ts +3 -4
- package/dist/published/stories/OverflowTextField.stories.d.ts +2 -4
- package/dist/published/stories/Palette.stories.d.ts +2 -3
- package/dist/published/stories/Paper.stories.d.ts +10 -4
- package/dist/published/stories/RadioGroup.stories.d.ts +2 -4
- package/dist/published/stories/RepeatableField.stories.d.ts +3 -4
- package/dist/published/stories/ResponsiveOverflow.stories.d.ts +3 -4
- package/dist/published/stories/RichTextViewer.stories.d.ts +2 -4
- package/dist/published/stories/Skeleton.stories.d.ts +10 -4
- package/dist/published/stories/Snackbar.stories.d.ts +9 -3
- package/dist/published/stories/Stack.stories.d.ts +10 -4
- package/dist/published/stories/StaticDatePicker.stories.d.ts +10 -5
- package/dist/published/stories/Stepper.stories.d.ts +12 -6
- package/dist/published/stories/Switch.stories.d.ts +2 -4
- package/dist/published/stories/Table.stories.d.ts +10 -4
- package/dist/published/stories/Tabs.stories.d.ts +10 -4
- package/dist/published/stories/TextField.stories.d.ts +6 -8
- package/dist/published/stories/TimePicker.stories.d.ts +2 -7
- package/dist/published/stories/TimePickerSelect.stories.d.ts +4 -6
- package/dist/published/stories/ToggleButton.stories.d.ts +10 -4
- package/dist/published/stories/Typography.stories.d.ts +2 -4
- package/dist/published/stories/sharedMswHandlers.js +5 -1
- package/package.json +17 -16
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useApiServices, useAuthenticationContext, } from '@evoke-platform/context';
|
|
2
2
|
import { WarningRounded } from '@mui/icons-material';
|
|
3
|
+
import DOMPurify from 'dompurify';
|
|
3
4
|
import React, { useEffect, useMemo } from 'react';
|
|
4
5
|
import { useResponsive } from '../../../../theme';
|
|
5
6
|
import { useFormContext } from '../../../../theme/hooks';
|
|
@@ -17,7 +18,7 @@ import { Image } from './FormFieldTypes/Image';
|
|
|
17
18
|
import ObjectPropertyInput from './FormFieldTypes/relatedObjectFiles/ObjectPropertyInput';
|
|
18
19
|
import UserProperty from './FormFieldTypes/UserProperty';
|
|
19
20
|
import FormSections from './FormSections';
|
|
20
|
-
import {
|
|
21
|
+
import { entryIsVisible, fetchCollectionData, filterEmptySections, getEntryId, getFieldDefinition, isAddressProperty, isOptionEqualToValue, updateCriteriaInputs, } from './utils';
|
|
21
22
|
function getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors, validation) {
|
|
22
23
|
return {
|
|
23
24
|
inputId: entryId,
|
|
@@ -42,39 +43,17 @@ export function RecursiveEntryRenderer(props) {
|
|
|
42
43
|
if (!entryIsVisible(entry, getValues(), instance)) {
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
46
|
+
const { isXs, smallerThan } = useResponsive();
|
|
47
|
+
const isSmallerThanMd = smallerThan('md');
|
|
45
48
|
const apiServices = useApiServices();
|
|
46
49
|
const userAccount = useAuthenticationContext()?.account;
|
|
47
|
-
const { smallerThan, isXs } = useResponsive();
|
|
48
50
|
const entryId = getEntryId(entry) || 'defaultId';
|
|
49
51
|
const display = 'display' in entry ? entry.display : undefined;
|
|
50
52
|
const fieldValue = entry.type === 'readonlyField' ? instance?.[entryId] : getValues(entryId);
|
|
51
53
|
const initialMiddleObjectInstances = fetchedOptions[`${entryId}InitialMiddleObjectInstances`];
|
|
52
54
|
const middleObject = fetchedOptions[`${entryId}MiddleObject`];
|
|
53
55
|
const fieldDefinition = useMemo(() => {
|
|
54
|
-
|
|
55
|
-
if (entry.type === 'input') {
|
|
56
|
-
def = parameters?.find((param) => param.id === entry.parameterId);
|
|
57
|
-
}
|
|
58
|
-
else if (entry.type === 'readonlyField') {
|
|
59
|
-
def = isDocument
|
|
60
|
-
? docProperties.find((prop) => prop.id === entry.propertyId)
|
|
61
|
-
: isAddressProperty(entry.propertyId)
|
|
62
|
-
? object?.properties?.find((prop) => prop.id === entry.propertyId.split('.')[0])
|
|
63
|
-
: object?.properties?.find((prop) => prop.id === entry.propertyId);
|
|
64
|
-
}
|
|
65
|
-
else if (entry.type === 'inputField') {
|
|
66
|
-
def = entry.input;
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
71
|
-
if (def?.enum && def.type === 'string') {
|
|
72
|
-
const cloned = structuredClone(def);
|
|
73
|
-
// single select must be made to be type choices for label and error handling
|
|
74
|
-
cloned.type = 'choices';
|
|
75
|
-
return cloned;
|
|
76
|
-
}
|
|
77
|
-
return def;
|
|
56
|
+
return getFieldDefinition(entry, object, parameters, isDocument);
|
|
78
57
|
}, [entry, parameters, object]);
|
|
79
58
|
const validation = fieldDefinition?.validation || {};
|
|
80
59
|
if (associatedObject?.propertyId === entryId)
|
|
@@ -85,7 +64,7 @@ export function RecursiveEntryRenderer(props) {
|
|
|
85
64
|
}
|
|
86
65
|
}, [fieldDefinition, instance]);
|
|
87
66
|
if (entry.type === 'content') {
|
|
88
|
-
return (React.createElement(Box, { dangerouslySetInnerHTML: { __html: entry.html }, sx: {
|
|
67
|
+
return (React.createElement(Box, { dangerouslySetInnerHTML: { __html: DOMPurify.sanitize(entry.html) }, sx: {
|
|
89
68
|
fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
|
|
90
69
|
} }));
|
|
91
70
|
}
|
|
@@ -123,7 +102,8 @@ export function RecursiveEntryRenderer(props) {
|
|
|
123
102
|
}
|
|
124
103
|
else if (fieldDefinition.type === 'richText') {
|
|
125
104
|
return (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) }, richTextEditor ? (React.createElement(richTextEditor, {
|
|
126
|
-
|
|
105
|
+
// RichTexts get a uniqueId when the form is loaded to prevent issues with multiple rich text fields on one form
|
|
106
|
+
id: entry.uniqueId,
|
|
127
107
|
value: fieldValue,
|
|
128
108
|
handleUpdate: (value) => handleChange(entryId, value),
|
|
129
109
|
format: 'rtf',
|
|
@@ -195,7 +175,8 @@ export function RecursiveEntryRenderer(props) {
|
|
|
195
175
|
}))))));
|
|
196
176
|
}
|
|
197
177
|
else if (entry.type === 'sections') {
|
|
198
|
-
|
|
178
|
+
const filteredEntry = filterEmptySections(entry, getValues(), instance);
|
|
179
|
+
return filteredEntry ? (isSmallerThanMd ? (React.createElement(AccordionSections, { entry: filteredEntry })) : (React.createElement(FormSections, { entry: filteredEntry }))) : null;
|
|
199
180
|
}
|
|
200
181
|
else if (!fieldDefinition) {
|
|
201
182
|
return (React.createElement(Box, { sx: {
|
|
@@ -64,13 +64,13 @@ export const handleValidation = (entries, register, formValues, parameters, inst
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
// Min/max number fields
|
|
67
|
-
if (isNumericValidation(validation) && validation.maximum) {
|
|
67
|
+
if (isNumericValidation(validation) && typeof validation.maximum === 'number') {
|
|
68
68
|
validationRules.max = {
|
|
69
69
|
value: validation.maximum,
|
|
70
70
|
message: errorMsg || `${fieldName} must have a value under ${validation.maximum}`,
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
-
if (isNumericValidation(validation) && validation.minimum) {
|
|
73
|
+
if (isNumericValidation(validation) && typeof validation.minimum === 'number') {
|
|
74
74
|
validationRules.min = {
|
|
75
75
|
value: validation.minimum,
|
|
76
76
|
message: errorMsg || `${fieldName} must have a value over ${validation.minimum}`,
|
|
@@ -83,6 +83,11 @@ export type BaseProps = {
|
|
|
83
83
|
export type ExpandedSection = Section & {
|
|
84
84
|
id: string;
|
|
85
85
|
expanded?: boolean;
|
|
86
|
+
isNested?: boolean;
|
|
87
|
+
};
|
|
88
|
+
export type ExpandedSections = Omit<Sections, 'sections'> & {
|
|
89
|
+
sections: ExpandedSection[];
|
|
90
|
+
id: string;
|
|
86
91
|
};
|
|
87
92
|
export type EntryRendererProps = BaseProps & {
|
|
88
93
|
entry: FormEntry;
|
|
@@ -93,7 +98,7 @@ export type EntryRendererProps = BaseProps & {
|
|
|
93
98
|
};
|
|
94
99
|
};
|
|
95
100
|
export type SectionsProps = {
|
|
96
|
-
entry:
|
|
101
|
+
entry: ExpandedSections;
|
|
97
102
|
};
|
|
98
103
|
export type DocumentData = {
|
|
99
104
|
id: string;
|
|
@@ -105,3 +110,6 @@ export type DocumentData = {
|
|
|
105
110
|
type: string;
|
|
106
111
|
view_permission: string;
|
|
107
112
|
};
|
|
113
|
+
export type RichTextFormEntry = FormEntry & {
|
|
114
|
+
uniqueId: string;
|
|
115
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/// <reference types="react" />
|
|
2
|
-
import { Action, ApiServices, Column, Columns, FormEntry, InputField, InputParameter, InputParameterReference, Obj, ObjectInstance, Property, Section, Sections, UserAccount } from '@evoke-platform/context';
|
|
2
|
+
import { Action, ApiServices, Column, Columns, EvokeForm, FormEntry, InputField, InputParameter, InputParameterReference, Obj, ObjectInstance, Property, Section, Sections, UserAccount } from '@evoke-platform/context';
|
|
3
3
|
import { LocalDateTime } from '@js-joda/core';
|
|
4
4
|
import { FieldErrors, FieldValues } from 'react-hook-form';
|
|
5
5
|
import { AutocompleteOption } from '../../../core';
|
|
@@ -81,8 +81,24 @@ export declare const deleteDocuments: (submittedFields: FieldValues, requestSucc
|
|
|
81
81
|
*
|
|
82
82
|
* Returns the cleaned submission ready for submitting.
|
|
83
83
|
*/
|
|
84
|
-
export declare function formatSubmission(submission: FieldValues, apiServices?: ApiServices, objectId?: string, instanceId?: string, setSnackbarError?: React.Dispatch<React.SetStateAction<{
|
|
84
|
+
export declare function formatSubmission(submission: FieldValues, apiServices?: ApiServices, objectId?: string, instanceId?: string, form?: EvokeForm, setSnackbarError?: React.Dispatch<React.SetStateAction<{
|
|
85
85
|
showAlert: boolean;
|
|
86
86
|
message?: string;
|
|
87
87
|
isError: boolean;
|
|
88
88
|
}>>): Promise<FieldValues>;
|
|
89
|
+
export declare function filterEmptySections(entry: Sections | Columns, formData: FieldValues, instance?: FieldValues): Sections | Columns | null;
|
|
90
|
+
export declare function assignIdsToSectionsAndRichText(entries: FormEntry[], object?: Obj, parameters?: InputParameter[]): FormEntry[];
|
|
91
|
+
/**
|
|
92
|
+
* Converts a plain text string to RTF format suitable for a RichTextEditor.
|
|
93
|
+
*
|
|
94
|
+
* Steps performed:
|
|
95
|
+
* 1. If the input is empty, returns a minimal RTF document with default font and size.
|
|
96
|
+
* 2. Escapes special RTF characters: backslashes, braces, line breaks, and tabs.
|
|
97
|
+
* 3. Removes carriage returns (\r) as they are unnecessary in RTF.
|
|
98
|
+
* 4. Converts non-ASCII characters (> 127) to Unicode escapes (\uN?) to preserve encoding.
|
|
99
|
+
* 5. Converts control characters (0-31, except \t, \n) to Unicode escapes to avoid RTF parsing errors.
|
|
100
|
+
*
|
|
101
|
+
* This ensures that any plain text input will be safely represented in RTF without losing formatting or characters.
|
|
102
|
+
*/
|
|
103
|
+
export declare function plainTextToRtf(plainText: string): string;
|
|
104
|
+
export declare function getFieldDefinition(entry: FormEntry, object?: Obj, parameters?: InputParameter[], isDocument?: boolean): InputParameter | Property | undefined;
|
|
@@ -2,6 +2,7 @@ import { LocalDateTime } from '@js-joda/core';
|
|
|
2
2
|
import jsonLogic from 'json-logic-js';
|
|
3
3
|
import { get, isArray, isEmpty, isObject, omit, pick, startCase, transform } from 'lodash';
|
|
4
4
|
import { DateTime } from 'luxon';
|
|
5
|
+
import { nanoid } from 'nanoid';
|
|
5
6
|
import Handlebars from 'no-eval-handlebars';
|
|
6
7
|
import { defaultRuleProcessorMongoDB, formatQuery, parseMongoDB } from 'react-querybuilder';
|
|
7
8
|
export const scrollIntoViewWithOffset = (el, offset, container) => {
|
|
@@ -624,15 +625,18 @@ export const deleteDocuments = async (submittedFields, requestSuccess, apiServic
|
|
|
624
625
|
*
|
|
625
626
|
* Returns the cleaned submission ready for submitting.
|
|
626
627
|
*/
|
|
627
|
-
export async function formatSubmission(submission, apiServices, objectId, instanceId, setSnackbarError) {
|
|
628
|
+
export async function formatSubmission(submission, apiServices, objectId, instanceId, form, setSnackbarError) {
|
|
628
629
|
for (const [key, value] of Object.entries(submission)) {
|
|
629
630
|
if (isArray(value)) {
|
|
630
631
|
const fileInArray = value.some((item) => item instanceof File);
|
|
631
632
|
if (fileInArray && instanceId && apiServices && objectId) {
|
|
632
633
|
try {
|
|
634
|
+
const allEntries = getUnnestedEntries(form?.entries ?? []) ?? [];
|
|
635
|
+
const entry = allEntries?.find((entry) => getEntryId(entry) === key);
|
|
633
636
|
const uploadedDocuments = await uploadDocuments(value, {
|
|
634
637
|
type: '',
|
|
635
638
|
view_permission: '',
|
|
639
|
+
...entry?.documentMetadata,
|
|
636
640
|
}, apiServices, instanceId, objectId);
|
|
637
641
|
submission[key] = uploadedDocuments;
|
|
638
642
|
}
|
|
@@ -668,3 +672,161 @@ export async function formatSubmission(submission, apiServices, objectId, instan
|
|
|
668
672
|
}
|
|
669
673
|
return submission;
|
|
670
674
|
}
|
|
675
|
+
export function filterEmptySections(entry, formData, instance) {
|
|
676
|
+
if (entry.type === 'sections' && isArray(entry.sections)) {
|
|
677
|
+
const visibleSections = entry.sections.filter((section) => {
|
|
678
|
+
if (!section.entries || section.entries.length === 0)
|
|
679
|
+
return false;
|
|
680
|
+
for (const sectionEntry of section.entries) {
|
|
681
|
+
if (sectionEntry.type === 'sections' || sectionEntry.type === 'columns') {
|
|
682
|
+
if (sectionEntry.visibility && !entryIsVisible(sectionEntry, formData, instance)) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
else if (filterEmptySections(sectionEntry, formData, instance)) {
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
else if (entryIsVisible(sectionEntry, formData, instance)) {
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return false;
|
|
694
|
+
});
|
|
695
|
+
if (visibleSections.length === 0)
|
|
696
|
+
return null;
|
|
697
|
+
return {
|
|
698
|
+
...entry,
|
|
699
|
+
sections: visibleSections,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
if (entry.type === 'columns' && isArray(entry.columns)) {
|
|
703
|
+
const visibleColumns = entry.columns.filter((column) => {
|
|
704
|
+
if (!column.entries || column.entries.length === 0)
|
|
705
|
+
return false;
|
|
706
|
+
let hasVisibleEntry = false;
|
|
707
|
+
for (const columnEntry of column.entries) {
|
|
708
|
+
if (columnEntry.type === 'sections' || columnEntry.type === 'columns') {
|
|
709
|
+
if (filterEmptySections(columnEntry, formData, instance)) {
|
|
710
|
+
hasVisibleEntry = true;
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
if (entryIsVisible(columnEntry, formData, instance)) {
|
|
716
|
+
hasVisibleEntry = true;
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return hasVisibleEntry;
|
|
722
|
+
});
|
|
723
|
+
if (visibleColumns.length === 0)
|
|
724
|
+
return null;
|
|
725
|
+
return {
|
|
726
|
+
...entry,
|
|
727
|
+
columns: visibleColumns,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return entry;
|
|
731
|
+
}
|
|
732
|
+
export function assignIdsToSectionsAndRichText(entries, object, parameters) {
|
|
733
|
+
return entries.map((entry) => {
|
|
734
|
+
if (entry.type === 'columns' && isArray(entry.columns)) {
|
|
735
|
+
return {
|
|
736
|
+
...entry,
|
|
737
|
+
columns: entry.columns.map((column) => ({
|
|
738
|
+
...column,
|
|
739
|
+
entries: isArray(column.entries)
|
|
740
|
+
? assignIdsToSectionsAndRichText(column.entries, object, parameters)
|
|
741
|
+
: [],
|
|
742
|
+
})),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
if (entry.type === 'sections' && isArray(entry.sections)) {
|
|
746
|
+
return {
|
|
747
|
+
...entry,
|
|
748
|
+
sections: entry.sections.map((section) => ({
|
|
749
|
+
...section,
|
|
750
|
+
id: nanoid(),
|
|
751
|
+
entries: isArray(section.entries)
|
|
752
|
+
? assignIdsToSectionsAndRichText(section.entries, object, parameters)
|
|
753
|
+
: [],
|
|
754
|
+
})),
|
|
755
|
+
id: nanoid(),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
// Assign a unique id to each RichText field to avoid conflicts when rendering more than one
|
|
759
|
+
const fieldDefinition = getFieldDefinition(entry, object, parameters);
|
|
760
|
+
if (fieldDefinition && fieldDefinition.type === 'richText') {
|
|
761
|
+
return {
|
|
762
|
+
...entry,
|
|
763
|
+
uniqueId: nanoid(),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
return entry;
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Converts a plain text string to RTF format suitable for a RichTextEditor.
|
|
771
|
+
*
|
|
772
|
+
* Steps performed:
|
|
773
|
+
* 1. If the input is empty, returns a minimal RTF document with default font and size.
|
|
774
|
+
* 2. Escapes special RTF characters: backslashes, braces, line breaks, and tabs.
|
|
775
|
+
* 3. Removes carriage returns (\r) as they are unnecessary in RTF.
|
|
776
|
+
* 4. Converts non-ASCII characters (> 127) to Unicode escapes (\uN?) to preserve encoding.
|
|
777
|
+
* 5. Converts control characters (0-31, except \t, \n) to Unicode escapes to avoid RTF parsing errors.
|
|
778
|
+
*
|
|
779
|
+
* This ensures that any plain text input will be safely represented in RTF without losing formatting or characters.
|
|
780
|
+
*/
|
|
781
|
+
export function plainTextToRtf(plainText) {
|
|
782
|
+
if (!plainText) {
|
|
783
|
+
return `{\\rtf1\\ansi\\deff0 {\\fonttbl {\\f0 Calibri;}} \\f0\\fs22 }`;
|
|
784
|
+
}
|
|
785
|
+
let escaped = plainText
|
|
786
|
+
.replace(/\\/g, '\\\\')
|
|
787
|
+
.replace(/{/g, '\\{')
|
|
788
|
+
.replace(/}/g, '\\}')
|
|
789
|
+
.replace(/\n/g, '\\par\n')
|
|
790
|
+
.replace(/\t/g, '\\tab ')
|
|
791
|
+
.replace(/\r/g, '');
|
|
792
|
+
// Handle non-ASCII characters (> 127) with Unicode escapes
|
|
793
|
+
escaped = escaped.replace(/[\u0080-\uffff]/g, (char) => {
|
|
794
|
+
const code = char.charCodeAt(0);
|
|
795
|
+
if (code > 32767) {
|
|
796
|
+
return `\\u${code - 65536}?`;
|
|
797
|
+
}
|
|
798
|
+
return `\\u${code}?`;
|
|
799
|
+
});
|
|
800
|
+
// Handle control characters (0-31 except \t, \n, \r which we already handled)
|
|
801
|
+
// eslint-disable-next-line no-control-regex
|
|
802
|
+
escaped = escaped.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, (char) => {
|
|
803
|
+
return `\\u${char.charCodeAt(0)}?`;
|
|
804
|
+
});
|
|
805
|
+
return `{\\rtf1\\ansi\\deff0 {\\fonttbl {\\f0 Calibri;}} \\f0\\fs22 ${escaped}}`;
|
|
806
|
+
}
|
|
807
|
+
export function getFieldDefinition(entry, object, parameters, isDocument) {
|
|
808
|
+
let def;
|
|
809
|
+
if (entry.type === 'input') {
|
|
810
|
+
def = parameters?.find((param) => param.id === entry.parameterId);
|
|
811
|
+
}
|
|
812
|
+
else if (entry.type === 'readonlyField') {
|
|
813
|
+
def = isDocument
|
|
814
|
+
? docProperties.find((prop) => prop.id === entry.propertyId)
|
|
815
|
+
: isAddressProperty(entry.propertyId)
|
|
816
|
+
? object?.properties?.find((prop) => prop.id === entry.propertyId.split('.')[0])
|
|
817
|
+
: object?.properties?.find((prop) => prop.id === entry.propertyId);
|
|
818
|
+
}
|
|
819
|
+
else if (entry.type === 'inputField') {
|
|
820
|
+
def = entry.input;
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
if (def?.enum && def.type === 'string') {
|
|
826
|
+
const cloned = structuredClone(def);
|
|
827
|
+
// single select must be made to be type choices for label and error handling
|
|
828
|
+
cloned.type = 'choices';
|
|
829
|
+
return cloned;
|
|
830
|
+
}
|
|
831
|
+
return def;
|
|
832
|
+
}
|
|
@@ -8,7 +8,7 @@ import React from 'react';
|
|
|
8
8
|
import { MemoryRouter } from 'react-router-dom';
|
|
9
9
|
import { expect, it } from 'vitest';
|
|
10
10
|
import FormRenderer from '../FormRenderer';
|
|
11
|
-
import { createSpecialtyForm, jsonLogicDisplayTestSpecialtyForm, licenseObject, npLicense, npSpecialtyType1, npSpecialtyType2, rnLicense, rnSpecialtyType1, rnSpecialtyType2, simpleConditionDisplayTestSpecialtyForm, specialtyObject, specialtyTypeObject, } from './test-data';
|
|
11
|
+
import { accessibility508Object, createSpecialtyForm, jsonLogicDisplayTestSpecialtyForm, licenseObject, npLicense, npSpecialtyType1, npSpecialtyType2, rnLicense, rnSpecialtyType1, rnSpecialtyType2, simpleConditionDisplayTestSpecialtyForm, specialtyObject, specialtyTypeObject, UpdateAccessibilityFormOne, UpdateAccessibilityFormTwo, users, } from './test-data';
|
|
12
12
|
expect.extend(matchers);
|
|
13
13
|
const removePoppers = () => {
|
|
14
14
|
const portalSelectors = ['.MuiAutocomplete-popper'];
|
|
@@ -35,6 +35,11 @@ describe('Form component', () => {
|
|
|
35
35
|
if (sanitizedVersion === 'true') {
|
|
36
36
|
return HttpResponse.json(specialtyObject);
|
|
37
37
|
}
|
|
38
|
+
}), http.get('/api/data/objects/accessibility508Object/effective', (req) => {
|
|
39
|
+
const sanitizedVersion = new URL(req.request.url).searchParams.get('sanitizedVersion');
|
|
40
|
+
if (sanitizedVersion === 'true') {
|
|
41
|
+
return HttpResponse.json(accessibility508Object);
|
|
42
|
+
}
|
|
38
43
|
}), http.get('/data/objects/license/effective', () => HttpResponse.json(licenseObject)), http.get('/api/data/objects/license/instances', () => {
|
|
39
44
|
return HttpResponse.json([rnLicense, npLicense]);
|
|
40
45
|
}), http.get('/api/data/objects/specialtyType/instances', (req) => {
|
|
@@ -56,7 +61,7 @@ describe('Form component', () => {
|
|
|
56
61
|
else if (isEqual(whereFilter, { and: [{ 'licenseType.id': 'npLicenseType' }, {}] }))
|
|
57
62
|
return HttpResponse.json([npSpecialtyType1, npSpecialtyType2]);
|
|
58
63
|
}
|
|
59
|
-
}));
|
|
64
|
+
}), http.get('/api/accessManagement/users', () => HttpResponse.json(users)));
|
|
60
65
|
server.listen();
|
|
61
66
|
});
|
|
62
67
|
afterAll(() => {
|
|
@@ -170,4 +175,208 @@ describe('Form component', () => {
|
|
|
170
175
|
expect(screen.queryByRole('combobox', { name: 'Specialty Type' })).not.toBeInTheDocument();
|
|
171
176
|
});
|
|
172
177
|
});
|
|
178
|
+
// accessibility508Form2
|
|
179
|
+
describe('508 accessibility compliance', () => {
|
|
180
|
+
it('supports keyboard navigation back and forth through Related Object dropdowns', async () => {
|
|
181
|
+
const user = userEvent.setup();
|
|
182
|
+
render(React.createElement(MemoryRouter, null,
|
|
183
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormOne, onChange: () => { } }),
|
|
184
|
+
","));
|
|
185
|
+
await waitFor(() => {
|
|
186
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
await user.tab();
|
|
189
|
+
// Name field should be focused
|
|
190
|
+
expect(screen.getByLabelText('Name')).toHaveFocus();
|
|
191
|
+
await user.tab();
|
|
192
|
+
// License should be focused
|
|
193
|
+
expect(screen.getByLabelText('License')).toHaveFocus();
|
|
194
|
+
// Check reverse tabbing
|
|
195
|
+
await user.tab({ shift: true });
|
|
196
|
+
expect(screen.getByLabelText('Name')).toHaveFocus();
|
|
197
|
+
});
|
|
198
|
+
it('supports keyboard navigation back and forth through User dropdowns', async () => {
|
|
199
|
+
const user = userEvent.setup();
|
|
200
|
+
render(React.createElement(MemoryRouter, null,
|
|
201
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormTwo, onChange: () => { } }),
|
|
202
|
+
","));
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
await user.tab();
|
|
207
|
+
// Name field should be focused
|
|
208
|
+
await waitFor(() => {
|
|
209
|
+
expect(screen.getByLabelText('Name')).toHaveFocus();
|
|
210
|
+
});
|
|
211
|
+
await user.tab();
|
|
212
|
+
// User should be focused
|
|
213
|
+
expect(screen.getByLabelText('User')).toHaveFocus();
|
|
214
|
+
// Check reverse tabbing
|
|
215
|
+
await user.tab({ shift: true });
|
|
216
|
+
expect(screen.getByLabelText('Name')).toHaveFocus();
|
|
217
|
+
});
|
|
218
|
+
it('supports keyboard selection of dropdown values using Enter key on Related Objects', async () => {
|
|
219
|
+
const user = userEvent.setup();
|
|
220
|
+
render(React.createElement(MemoryRouter, null,
|
|
221
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormOne, onChange: () => { } }),
|
|
222
|
+
","));
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
// Navigate to License field
|
|
227
|
+
await user.tab();
|
|
228
|
+
await user.tab();
|
|
229
|
+
// Open dropdown with Enter
|
|
230
|
+
await user.keyboard('{Enter}');
|
|
231
|
+
// Navigate to first option
|
|
232
|
+
await user.keyboard('{ArrowDown}');
|
|
233
|
+
// Select option with Enter
|
|
234
|
+
await user.keyboard('{Enter}');
|
|
235
|
+
await waitFor(() => {
|
|
236
|
+
expect(screen.getByText('RN License')).toBeInTheDocument();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
it('supports keyboard selection of dropdown values using Enter key on Users', async () => {
|
|
240
|
+
const user = userEvent.setup();
|
|
241
|
+
render(React.createElement(MemoryRouter, null,
|
|
242
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormTwo, onChange: () => { } }),
|
|
243
|
+
","));
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
246
|
+
});
|
|
247
|
+
// Navigate to License field
|
|
248
|
+
await user.tab();
|
|
249
|
+
await user.tab();
|
|
250
|
+
// Open dropdown with Enter
|
|
251
|
+
await user.keyboard('{Enter}');
|
|
252
|
+
// Navigate to first option
|
|
253
|
+
await user.keyboard('{ArrowDown}');
|
|
254
|
+
// Select option with Enter
|
|
255
|
+
await user.keyboard('{Enter}');
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
const input = screen.getByRole('combobox', { name: 'User' });
|
|
258
|
+
expect(input).toHaveValue('User 1');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
it('supports navigating between dropdown options using arrow keys on Related Objects', async () => {
|
|
262
|
+
const user = userEvent.setup();
|
|
263
|
+
render(React.createElement(MemoryRouter, null,
|
|
264
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormOne, onChange: () => { } }),
|
|
265
|
+
","));
|
|
266
|
+
await waitFor(() => {
|
|
267
|
+
expect(screen.getByLabelText('License')).toBeInTheDocument();
|
|
268
|
+
});
|
|
269
|
+
// Navigate to and open dropdown
|
|
270
|
+
await user.tab();
|
|
271
|
+
await user.tab();
|
|
272
|
+
await user.keyboard('{ArrowDown}');
|
|
273
|
+
await waitFor(() => {
|
|
274
|
+
expect(screen.getByText('RN License')).toBeInTheDocument();
|
|
275
|
+
});
|
|
276
|
+
await user.keyboard('{ArrowDown}');
|
|
277
|
+
await user.keyboard('{Enter}');
|
|
278
|
+
// Verify first selection
|
|
279
|
+
await waitFor(() => {
|
|
280
|
+
expect(screen.getByText('RN License')).toBeInTheDocument();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
it('supports navigating between dropdown options using arrow keys on User dropdowns', async () => {
|
|
284
|
+
const user = userEvent.setup();
|
|
285
|
+
render(React.createElement(MemoryRouter, null,
|
|
286
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormTwo, onChange: () => { } }),
|
|
287
|
+
","));
|
|
288
|
+
await waitFor(() => {
|
|
289
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
290
|
+
});
|
|
291
|
+
// Navigate to and open dropdown
|
|
292
|
+
await user.tab();
|
|
293
|
+
await user.tab();
|
|
294
|
+
await user.keyboard('{ArrowDown}');
|
|
295
|
+
await user.keyboard('{ArrowDown}');
|
|
296
|
+
await user.keyboard('{Enter}');
|
|
297
|
+
// Verify first selection
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
const input = screen.getByRole('combobox', { name: 'User' });
|
|
300
|
+
expect(input).toHaveValue('User 1');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
it('supports clearing selection with the clear button on Related Objects', async () => {
|
|
304
|
+
const user = userEvent.setup();
|
|
305
|
+
render(React.createElement(MemoryRouter, null,
|
|
306
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormOne, onChange: () => { }, value: {
|
|
307
|
+
id: '123',
|
|
308
|
+
objectId: 'accessibility508Object',
|
|
309
|
+
name: 'Test Accessibility 508 Object Instance',
|
|
310
|
+
license: {
|
|
311
|
+
id: 'rnLicense',
|
|
312
|
+
name: 'RN License',
|
|
313
|
+
},
|
|
314
|
+
} })));
|
|
315
|
+
// Set up a selection first
|
|
316
|
+
await waitFor(() => {
|
|
317
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
318
|
+
});
|
|
319
|
+
await waitFor(() => {
|
|
320
|
+
expect(screen.getByText('RN License')).toBeInTheDocument();
|
|
321
|
+
});
|
|
322
|
+
// Manually focus the clear button since test environment can't reach it via tab,
|
|
323
|
+
// even though tabbing works correctly in the actual form
|
|
324
|
+
const clearButton = screen.getByRole('button', { name: 'Clear selection' });
|
|
325
|
+
clearButton.focus();
|
|
326
|
+
expect(clearButton).toHaveFocus();
|
|
327
|
+
await user.keyboard('{Enter}');
|
|
328
|
+
await waitFor(() => {
|
|
329
|
+
expect(screen.queryByText('RN License')).not.toBeInTheDocument();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
it('supports clearing selection with the clear button on User dropdowns', async () => {
|
|
333
|
+
const user = userEvent.setup();
|
|
334
|
+
render(React.createElement(MemoryRouter, null,
|
|
335
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormTwo, onChange: () => { }, value: {
|
|
336
|
+
id: '123',
|
|
337
|
+
objectId: 'accessibility508Object',
|
|
338
|
+
name: 'Test Accessibility 508 Object Instance',
|
|
339
|
+
user: {
|
|
340
|
+
id: 'user1',
|
|
341
|
+
name: 'User 1',
|
|
342
|
+
},
|
|
343
|
+
} })));
|
|
344
|
+
// Set up a selection first
|
|
345
|
+
await waitFor(() => {
|
|
346
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
await waitFor(() => {
|
|
349
|
+
const input = screen.getByRole('combobox', { name: 'User' });
|
|
350
|
+
expect(input).toHaveValue('User 1');
|
|
351
|
+
});
|
|
352
|
+
// Manually focus the clear button since test environment can't reach it via tab,
|
|
353
|
+
// even though tabbing works correctly in the actual form
|
|
354
|
+
const clearButton = screen.getByRole('button', { name: 'Clear selection' });
|
|
355
|
+
clearButton.focus();
|
|
356
|
+
expect(clearButton).toHaveFocus();
|
|
357
|
+
await user.keyboard('{Enter}');
|
|
358
|
+
await waitFor(() => {
|
|
359
|
+
const input = screen.getByRole('combobox', { name: 'User' });
|
|
360
|
+
expect(input).toHaveValue('');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
it('supports navigating to Add New option with arrow keys and opens modal', async () => {
|
|
364
|
+
const user = userEvent.setup();
|
|
365
|
+
render(React.createElement(MemoryRouter, null,
|
|
366
|
+
React.createElement(FormRenderer, { form: UpdateAccessibilityFormOne, onChange: () => { } }),
|
|
367
|
+
","));
|
|
368
|
+
await waitFor(() => {
|
|
369
|
+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
|
370
|
+
});
|
|
371
|
+
// Navigate to and open dropdown
|
|
372
|
+
await user.tab();
|
|
373
|
+
await user.tab();
|
|
374
|
+
await user.keyboard('{Enter}');
|
|
375
|
+
await user.keyboard('{ArrowUp}'); // Navigate to "Add New" option
|
|
376
|
+
await user.keyboard('{Enter}');
|
|
377
|
+
await waitFor(() => {
|
|
378
|
+
expect(screen.getByRole('dialog', { name: /add license/i })).toBeInTheDocument();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
173
382
|
});
|
|
@@ -2,10 +2,13 @@ import { EvokeForm, Obj, ObjectInstance } from '@evoke-platform/context';
|
|
|
2
2
|
export declare const createSpecialtyForm: EvokeForm;
|
|
3
3
|
export declare const simpleConditionDisplayTestSpecialtyForm: EvokeForm;
|
|
4
4
|
export declare const jsonLogicDisplayTestSpecialtyForm: EvokeForm;
|
|
5
|
+
export declare const UpdateAccessibilityFormOne: EvokeForm;
|
|
6
|
+
export declare const UpdateAccessibilityFormTwo: EvokeForm;
|
|
5
7
|
export declare const licenseObject: Obj;
|
|
6
8
|
export declare const licenseTypeObject: Obj;
|
|
7
9
|
export declare const specialtyObject: Obj;
|
|
8
10
|
export declare const specialtyTypeObject: Obj;
|
|
11
|
+
export declare const accessibility508Object: Obj;
|
|
9
12
|
export declare const rnLicense: ObjectInstance;
|
|
10
13
|
export declare const npLicense: ObjectInstance;
|
|
11
14
|
export declare const rnLicenseType: ObjectInstance;
|
|
@@ -14,3 +17,9 @@ export declare const rnSpecialtyType1: ObjectInstance;
|
|
|
14
17
|
export declare const rnSpecialtyType2: ObjectInstance;
|
|
15
18
|
export declare const npSpecialtyType1: ObjectInstance;
|
|
16
19
|
export declare const npSpecialtyType2: ObjectInstance;
|
|
20
|
+
export declare const users: {
|
|
21
|
+
id: string;
|
|
22
|
+
status: string;
|
|
23
|
+
email: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}[];
|