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); + }); +});