diff --git a/src/ReportFormSummary/V2SchemaFormSummary/index.js b/src/ReportFormSummary/V2SchemaFormSummary/index.js
index dde52b1d1..e9c1166c4 100644
--- a/src/ReportFormSummary/V2SchemaFormSummary/index.js
+++ b/src/ReportFormSummary/V2SchemaFormSummary/index.js
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { FORM_ELEMENT_TYPES, ROOT_CANVAS_ID } from '../../utils/form-schemas/constants';
import getHumanizedFieldValue from '../../utils/form-schemas/getHumanizedFieldValue';
+import normalizeChoiceListValues from '../../utils/form-schemas/normalizeChoiceListValues';
import { selectCoordinatesRepresentation } from '../../selectors/location';
import transformSchemaToFormElements from '../../utils/form-schemas/transformSchemaToFormElements';
@@ -55,8 +56,10 @@ const SectionSummary = ({ formData, formElements, section }) =>
{
const formElements = useMemo(() => transformSchemaToFormElements(eventSchema), [eventSchema]);
+ const normalizedFormData = useMemo(() => normalizeChoiceListValues(formData, formElements), [formData, formElements]);
+
return formElements[ROOT_CANVAS_ID]?.details.sections.map((sectionId) => {
expect(screen.getByText('Text Field')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
+
+ test('shows the display names of a legacy choice list stored as { name, value } objects', () => {
+ const eventSchema = {
+ json: {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ properties: {
+ team_members: {
+ items: {
+ anyOf: [{
+ enum: ['kumoi_njapit', 'sam_kumum'],
+ 'x-enumExtra': {
+ kumoi_njapit: { display: 'Kumoi Njapit' },
+ sam_kumum: { display: 'Sam Kumum' },
+ },
+ }],
+ },
+ title: 'Team Member',
+ type: 'array',
+ },
+ },
+ required: [],
+ type: 'object',
+ unevaluatedProperties: false,
+ },
+ ui: {
+ fields: { team_members: { inputType: 'LIST', parent: 'section-1', type: 'CHOICE_LIST' } },
+ headers: {},
+ order: ['section-1'],
+ sections: {
+ 'section-1': {
+ columns: 1,
+ isActive: true,
+ label: 'Details',
+ leftColumn: [{ name: 'team_members', type: 'field' }],
+ rightColumn: [],
+ },
+ },
+ },
+ };
+
+ renderV2SchemaFormSummary({
+ eventSchema,
+ formData: {
+ team_members: [
+ { name: 'Kumoi Njapit', value: 'kumoi_njapit' },
+ { name: 'Sam Kumum', value: 'sam_kumum' },
+ ],
+ },
+ });
+
+ expect(screen.getByText('Team Member')).toBeInTheDocument();
+ expect(screen.getByText('Kumoi Njapit, Sam Kumum')).toBeInTheDocument();
+ });
});
diff --git a/src/ReportManager/ReportDetailView/index.js b/src/ReportManager/ReportDetailView/index.js
index 72e3d077d..f9629e0ef 100644
--- a/src/ReportManager/ReportDetailView/index.js
+++ b/src/ReportManager/ReportDetailView/index.js
@@ -31,12 +31,14 @@ import { generateErrorMessageForRequest } from '../../utils/request';
import { extractObjectDifference } from '../../utils/objects';
import { fetchEventTypeSchema } from '../../ducks/event-schemas';
import { fetchPatrol } from '../../ducks/patrols';
+import normalizeChoiceListValues from '../../utils/form-schemas/normalizeChoiceListValues';
import { selectEventSchema } from '../../selectors/event-schemas';
import { selectEventTypeById, selectEventTypeByValue } from '../../selectors/event-types';
import { setLocallyEditedEvent, unsetLocallyEditedEvent } from '../../ducks/locally-edited-event';
import { SidebarScrollContext } from '../../SidebarScrollContext';
import { TAB_KEYS } from '../../constants';
import { TrackerContext } from '../../utils/analytics';
+import transformSchemaToFormElements from '../../utils/form-schemas/transformSchemaToFormElements';
import useNavigate from '../../hooks/useNavigate';
import { usePreviewFeature } from '../../hooks';
import { uuid } from '../../utils/string';
@@ -172,6 +174,13 @@ const ReportDetailView = ({
? selectEventSchema(state, reportForm.event_type, reportForm.id)
: null);
+ const formElements = useMemo(
+ () => eventType?.version === 2 && eventSchema?.json && !eventSchema?.error
+ ? transformSchemaToFormElements(eventSchema)
+ : null,
+ [eventSchema, eventType?.version]
+ );
+
const {
onCancelAddedReport,
onSaveError: onSaveErrorCallback,
@@ -329,8 +338,8 @@ const ReportDetailView = ({
} else {
reportToSubmit = {
...reportChanges,
+ event_details: normalizeChoiceListValues(reportForm.event_details, formElements),
id: reportForm.id,
- event_details: reportForm.event_details,
location: originalReport.location,
};
@@ -382,6 +391,7 @@ const ReportDetailView = ({
attachmentsToAdd,
communityInputValue,
dispatch,
+ formElements,
isAddedReport,
isCommunity,
isNewReport,
diff --git a/src/ReportManager/ReportDetailView/index.test.js b/src/ReportManager/ReportDetailView/index.test.js
index d533d8609..d0efb8b96 100644
--- a/src/ReportManager/ReportDetailView/index.test.js
+++ b/src/ReportManager/ReportDetailView/index.test.js
@@ -11,7 +11,7 @@ import { addEventToIncident, createEvent, fetchEvent } from '../../ducks/events'
import { activePatrol } from '../../__test-helpers/fixtures/patrols';
import { createMapMock } from '../../__test-helpers/mocks';
import { eventSchemas } from '../../__test-helpers/fixtures/event-schemas';
-import { eventTypes } from '../../__test-helpers/fixtures/event-types';
+import { eventTypes, snareV2 } from '../../__test-helpers/fixtures/event-types';
import { executeSaveActions, generateSaveActionsForReportLikeObject } from '../../utils/save';
import { TrackerContext } from '../../utils/analytics';
import { fetchEventTypeSchema } from '../../ducks/event-schemas';
@@ -112,6 +112,65 @@ describe('ReportManager - ReportDetailView', () => {
user: { first_name: 'First', last_name: 'Last' },
}],
};
+ const setUpLegacyChoiceListEvent = () => {
+ state.data.eventTypes = [...eventTypes, snareV2];
+ state.data.eventSchemas = {
+ ...eventSchemas,
+ [snareV2.value]: {
+ 792: {
+ json: {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ properties: {
+ team_members: {
+ items: {
+ anyOf: [{
+ enum: ['kumoi_njapit', 'sam_kumum'],
+ 'x-enumExtra': {
+ kumoi_njapit: { display: 'Kumoi Njapit' },
+ sam_kumum: { display: 'Sam Kumum' },
+ },
+ }],
+ },
+ title: 'Team Member',
+ type: 'array',
+ },
+ },
+ required: [],
+ type: 'object',
+ unevaluatedProperties: false,
+ },
+ ui: {
+ fields: { team_members: { inputType: 'LIST', parent: 'section-1', type: 'CHOICE_LIST' } },
+ headers: {},
+ order: ['section-1'],
+ sections: {
+ 'section-1': {
+ columns: 1,
+ isActive: true,
+ label: 'Details',
+ leftColumn: [{ name: 'team_members', type: 'field' }],
+ rightColumn: [],
+ },
+ },
+ },
+ },
+ },
+ };
+ state.data.eventStore = {
+ ...state.data.eventStore,
+ 792: {
+ ...mockReport,
+ event_type: snareV2.value,
+ event_details: {
+ team_members: [
+ { name: 'Kumoi Njapit', value: 'kumoi_njapit' },
+ { name: 'Sam Kumum', value: 'sam_kumum' },
+ ],
+ },
+ id: '792',
+ },
+ };
+ };
let AddItemButtonMock,
addEventToIncidentMock,
createEventMock,
@@ -502,6 +561,24 @@ describe('ReportManager - ReportDetailView', () => {
});
});
+ test('renders and saves the option values of a legacy V2 choice list stored as { name, value } objects', async () => {
+ setUpLegacyChoiceListEvent();
+
+ renderWithWrapper();
+
+ expect(await screen.findByRole('checkbox', { name: 'Kumoi Njapit' })).toBeChecked();
+ expect(await screen.findByRole('checkbox', { name: 'Sam Kumum' })).toBeChecked();
+
+ await userEvent.click(await screen.findByText('Save'));
+
+ await waitFor(() => {
+ expect(generateSaveActionsForReportLikeObject).toHaveBeenCalledTimes(1);
+ });
+ expect(generateSaveActionsForReportLikeObject.mock.calls[0][0].event_details).toEqual({
+ team_members: ['kumoi_njapit', 'sam_kumum'],
+ });
+ });
+
test('still blocks saving when a cleared legacy dropdown is required by the schema', async () => {
const accidentSchema = eventSchemas.accident_rep.base;
state.data.eventSchemas = {
diff --git a/src/SchemaForm/index.js b/src/SchemaForm/index.js
index 893d979a4..d2ae2ba4c 100644
--- a/src/SchemaForm/index.js
+++ b/src/SchemaForm/index.js
@@ -8,6 +8,7 @@ import { clearUserContent } from '../ducks/user-content';
import evaluateSectionConditions from './utils/evaluateSectionConditions';
import { FORM_ELEMENT_TYPES, ROOT_CANVAS_ID } from '../utils/form-schemas/constants';
import getDefaultFormData from './utils/getDefaultFormData';
+import normalizeChoiceListValues from '../utils/form-schemas/normalizeChoiceListValues';
import normalizeDateTimeFieldValue from './utils/normalizeDateTimeFieldValue';
import transformSchemaToFormElements from '../utils/form-schemas/transformSchemaToFormElements';
import useMapLocationMarkers from './utils/useMapLocationMarkers';
@@ -82,19 +83,21 @@ const SchemaForm = ({
const formElements = useMemo(() => transformSchemaToFormElements(schema), [schema]);
+ const normalizedFormData = useMemo(() => normalizeChoiceListValues(formData, formElements), [formData, formElements]);
+
const runSchemaValidations = useSchemaValidations(schema);
const runUploadValidations = useUploadValidations(formElements);
const visibleSectionIds = useMemo(
- () => getVisibleSectionIds(formElements, formData),
- [formData, formElements]
+ () => getVisibleSectionIds(formElements, normalizedFormData),
+ [formElements, normalizedFormData]
);
const onSubmit = (event) => {
event.preventDefault();
- const schemaErrors = runSchemaValidations(formData) || {};
- const uploadErrors = runUploadValidations(formData);
+ const schemaErrors = runSchemaValidations(normalizedFormData) || {};
+ const uploadErrors = runUploadValidations(normalizedFormData);
const fieldErrors = merge({}, schemaErrors, uploadErrors);
if (Object.keys(fieldErrors).length > 0) {
const erroneousFields = Object.keys(fieldErrors);
@@ -116,7 +119,7 @@ const SchemaForm = ({
const onSectionFieldChange = (fieldId, value) => {
// Section children's ids and names are the same.
const fieldName = formElements[fieldId].details.value;
- const newFormData = { ...formData, [fieldName]: value };
+ const newFormData = { ...normalizedFormData, [fieldName]: value };
// Conditional sections can depend on fields in other conditional sections.
// Remove hidden fields from the form data in a loop until all sections
@@ -232,7 +235,7 @@ const SchemaForm = ({
]);
const initialData = getDefaultFormData(visibleFieldIds, formElements);
- if (!isEqual(initialData, formData)) {
+ if (!isEqual(initialData, normalizedFormData)) {
onFormDataChange(initialData);
}
}
@@ -240,7 +243,7 @@ const SchemaForm = ({
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldCalculateInitialData(false);
}
- }, [formData, formElements, onFormDataChange, shouldPopulateDefaultData, shouldCalculateInitialData, visibleSectionIds]);
+ }, [formElements, normalizedFormData, onFormDataChange, shouldPopulateDefaultData, shouldCalculateInitialData, visibleSectionIds]);
useEffect(() => {
// Update the location markers when there is a change in the form data.
@@ -266,10 +269,10 @@ const SchemaForm = ({
});
};
- addLocationMarkersFromFormDataRecursively(formData);
+ addLocationMarkersFromFormDataRecursively(normalizedFormData);
setLocationMarkers(locationMarkers);
- }, [formData, formElements, setLocationMarkers]);
+ }, [formElements, normalizedFormData, setLocationMarkers]);
useEffect(() => () => dispatch(clearUserContent()), [dispatch]);
@@ -290,7 +293,7 @@ const SchemaForm = ({
details={formElements[sectionId].details}
fieldErrors={fieldErrors}
focusLocationMarker={focusLocationMarker}
- formData={formData}
+ formData={normalizedFormData}
formElements={formElements}
hidden={!visibleSectionIds.includes(sectionId)}
id={sectionId}
@@ -298,7 +301,7 @@ const SchemaForm = ({
onFieldChange={onSectionFieldChange}
onFieldErrorsChange={(newFieldErrors) => setFieldErrors(newFieldErrors)}
renderFormElement={renderFormElement}
- setDefaultFormData={(defaultFormData) => onFormDataChange({ ...defaultFormData, ...formData })}
+ setDefaultFormData={(defaultFormData) => onFormDataChange({ ...defaultFormData, ...normalizedFormData })}
/>)}
{renderSubmitButton()}
diff --git a/src/utils/form-schemas/normalizeChoiceListValues/index.js b/src/utils/form-schemas/normalizeChoiceListValues/index.js
new file mode 100644
index 000000000..12218eedf
--- /dev/null
+++ b/src/utils/form-schemas/normalizeChoiceListValues/index.js
@@ -0,0 +1,54 @@
+import { isPlainObject } from 'lodash-es';
+
+import { FORM_ELEMENT_TYPES } from '../constants';
+
+const isLegacyChoiceValue = (value) => isPlainObject(value)
+ && typeof value.name === 'string'
+ && ['boolean', 'number', 'string'].includes(typeof value.value);
+
+const normalizeChoiceValue = (value) => isLegacyChoiceValue(value) ? value.value : value;
+
+const normalizeArrayItems = (array, normalizeItem) => {
+ const normalizedArray = array.map(normalizeItem);
+
+ return normalizedArray.some((item, index) => item !== array[index]) ? normalizedArray : array;
+};
+
+const normalizeObjectValues = (object, normalizeValue) => {
+ const normalizedEntries = Object.entries(object).map(([key, value]) => [key, normalizeValue(value, key)]);
+
+ return normalizedEntries.some(([key, value]) => value !== object[key])
+ ? Object.fromEntries(normalizedEntries)
+ : object;
+};
+
+const normalizeFieldValues = (formData, formElements, parentCollectionFieldId = null) => {
+ if (!isPlainObject(formData)) {
+ return formData;
+ }
+
+ return normalizeObjectValues(formData, (value, fieldName) => {
+ const fieldId = parentCollectionFieldId ? `${parentCollectionFieldId}.${fieldName}` : fieldName;
+
+ switch (formElements[fieldId]?.type) {
+ case FORM_ELEMENT_TYPES.CHOICE_LIST:
+ return Array.isArray(value) ? normalizeArrayItems(value, normalizeChoiceValue) : normalizeChoiceValue(value);
+
+ case FORM_ELEMENT_TYPES.COLLECTION:
+ return Array.isArray(value)
+ ? normalizeArrayItems(value, (item) => normalizeFieldValues(item, formElements, fieldId))
+ : value;
+
+ default:
+ return value;
+ }
+ });
+};
+
+// Replaces the values of the choice list fields described by formElements from
+// { name, value } format to their value.
+const normalizeChoiceListValues = (formData, formElements) => isPlainObject(formElements)
+ ? normalizeFieldValues(formData, formElements)
+ : formData;
+
+export default normalizeChoiceListValues;
diff --git a/src/utils/form-schemas/normalizeChoiceListValues/index.test.js b/src/utils/form-schemas/normalizeChoiceListValues/index.test.js
new file mode 100644
index 000000000..7df609af9
--- /dev/null
+++ b/src/utils/form-schemas/normalizeChoiceListValues/index.test.js
@@ -0,0 +1,125 @@
+import { FORM_ELEMENT_TYPES } from '../constants';
+import normalizeChoiceListValues from './';
+
+describe('normalizeChoiceListValues', () => {
+ const formElements = {
+ maintenance_action: { type: FORM_ELEMENT_TYPES.CHOICE_LIST },
+ team_members: { type: FORM_ELEMENT_TYPES.CHOICE_LIST },
+ type_accident: { type: FORM_ELEMENT_TYPES.TEXT },
+ sightings: { type: FORM_ELEMENT_TYPES.COLLECTION },
+ 'sightings.species': { type: FORM_ELEMENT_TYPES.CHOICE_LIST },
+ 'sightings.count': { type: FORM_ELEMENT_TYPES.NUMERIC },
+ custom_attributes: { type: FORM_ELEMENT_TYPES.COLLECTION },
+ 'custom_attributes.name': { type: FORM_ELEMENT_TYPES.TEXT },
+ 'custom_attributes.value': { type: FORM_ELEMENT_TYPES.TEXT },
+ };
+
+ it('replaces a legacy choice object with its value', () => {
+ const formData = { maintenance_action: { name: 'Colocacion de camara', value: 'colocaciondecamara' } };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual({ maintenance_action: 'colocaciondecamara' });
+ });
+
+ it('replaces the legacy choice objects of a multiple choice list', () => {
+ const formData = {
+ team_members: [
+ { name: 'Kumoi Njapit', value: 'kumoi_njapit' },
+ { name: 'Sam Kumum', value: 'sam_kumum' },
+ ],
+ };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual({ team_members: ['kumoi_njapit', 'sam_kumum'] });
+ });
+
+ it('replaces legacy choice objects holding non string values', () => {
+ const formData = { maintenance_action: { name: 'Two', value: 2 }, team_members: [{ name: 'Yes', value: true }] };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual({ maintenance_action: 2, team_members: [true] });
+ });
+
+ it('replaces legacy choice objects of a choice list inside a collection', () => {
+ const formData = {
+ sightings: [
+ { species: { name: 'Elephant', value: 'elephant' }, count: 3 },
+ { species: { name: 'Zebra', value: 'zebra' }, count: 1 },
+ ],
+ };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual({
+ sightings: [{ species: 'elephant', count: 3 }, { species: 'zebra', count: 1 }],
+ });
+ });
+
+ it('keeps the items of a collection whose fields are a name and a value', () => {
+ const formData = {
+ custom_attributes: [{ name: 'Colour', value: 'red' }, { name: 'Size', value: 'XL' }],
+ };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual(formData);
+ });
+
+ it('leaves the values of fields that are not choice lists untouched', () => {
+ const formData = { type_accident: { name: 'Truck crash', value: 'truck_crash' } };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual(formData);
+ });
+
+ it('leaves fields missing from the form elements untouched', () => {
+ const formData = { removed_field: { name: 'Kumoi Njapit', value: 'kumoi_njapit' } };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual(formData);
+ });
+
+ it('leaves the option maps that section conditions use untouched', () => {
+ const formData = { maintenance_action: { colocaciondecamara: true } };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual(formData);
+ });
+
+ it('leaves already normalized values untouched', () => {
+ const formData = { maintenance_action: 'colocaciondecamara', team_members: ['kumoi_njapit'] };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual(formData);
+ });
+
+ it('keeps cleared and empty values as they are', () => {
+ const formData = { maintenance_action: '', team_members: [], sightings: null };
+
+ expect(normalizeChoiceListValues(formData, formElements)).toEqual(formData);
+ });
+
+ it('returns the form data as it is when there are no form elements', () => {
+ const formData = { maintenance_action: { name: 'Colocacion de camara', value: 'colocaciondecamara' } };
+
+ expect(normalizeChoiceListValues(formData, null)).toBe(formData);
+ expect(normalizeChoiceListValues(formData, undefined)).toBe(formData);
+ });
+
+ it('returns non object form data as it is', () => {
+ expect(normalizeChoiceListValues(undefined, formElements)).toBeUndefined();
+ expect(normalizeChoiceListValues(null, formElements)).toBeNull();
+ expect(normalizeChoiceListValues('kumoi_njapit', formElements)).toBe('kumoi_njapit');
+ });
+
+ it('preserves the reference of form data that needs no changes', () => {
+ const formData = {
+ team_members: ['kumoi_njapit'],
+ sightings: [{ species: 'elephant', count: 3 }],
+ };
+
+ const normalizedFormData = normalizeChoiceListValues(formData, formElements);
+
+ expect(normalizedFormData).toBe(formData);
+ expect(normalizedFormData.team_members).toBe(formData.team_members);
+ expect(normalizedFormData.sightings).toBe(formData.sightings);
+ });
+
+ it('preserves the reference of the values that need no changes', () => {
+ const formData = {
+ team_members: ['kumoi_njapit'],
+ maintenance_action: { name: 'Colocacion de camara', value: 'colocaciondecamara' },
+ };
+
+ expect(normalizeChoiceListValues(formData, formElements).team_members).toBe(formData.team_members);
+ });
+});