diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramMetadata.js b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramMetadata.js index d319df06d1..542e07dfe6 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramMetadata.js +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramMetadata.js @@ -1,28 +1,99 @@ // @flow -import { useEffect } from 'react'; -import { useDataQuery } from '@dhis2/app-runtime'; - -const query = { - programData: { - resource: 'programs', - id: ({ id }) => id, - params: { - fields: - ['programStages[id,repeatable,hideDueDate,programStageDataElements[displayInReports,dataElement[id,valueType,displayName,displayFormName,optionSet[options[code,name]]]'], - }, - }, -}; +import { useMemo } from 'react'; +import { useProgramFromIndexedDB } from '../../../../../utils/cachedDataHooks/useProgramFromIndexedDB'; +import { useDataElementsFromIndexedDB } from '../../../../../utils/cachedDataHooks/useDataElementsFromIndexedDB'; +import { useOptionSetsFromIndexedDB } from '../../../../../utils/cachedDataHooks/useOptionSetsFromIndexedDB'; + +const queryKey = 'useProgramMetadata'; export const useProgramMetadata = (programId: string) => { - const { data, error, loading, refetch } = useDataQuery(query, { - lazy: true, - }); + const { program, isLoading, isError } = useProgramFromIndexedDB(programId, { enabled: !!programId }); + + const dataElementIds = useMemo(() => + (program ? program.programStages.reduce( + (acc, stage) => stage.programStageDataElements.reduce( + (accIds, dataElement) => { + accIds.add(dataElement.dataElementId); + return accIds; + }, acc), + new Set) : undefined), [program]); + + const { + isLoading: loadingDataElements, + dataElements, + isError: dataElementsError, + } = useDataElementsFromIndexedDB([queryKey, programId], dataElementIds); + + const derivedDataElementValues = useMemo(() => + (dataElements ? ({ + optionSetIds: dataElements.reduce((acc, dataElement) => { + if (dataElement.optionSetValue) { + acc.add(dataElement.optionSet.id); + } + return acc; + }, new Set), + dataElementDictionary: dataElements.reduce((acc, dataElement) => { + acc[dataElement.id] = dataElement; + return acc; + }, {}), + }) : undefined), [dataElements]); - useEffect(() => { - if (programId) { - refetch({ id: programId }); + const { + isLoading: loadingOptionSets, + optionSets, + isError: optionSetsError, + } = useOptionSetsFromIndexedDB([queryKey, programId], derivedDataElementValues && derivedDataElementValues.optionSetIds); + + const optionSetDictionary = useMemo( + () => (optionSets ? optionSets.reduce( + (acc, optionSet) => { + acc[optionSet.id] = { + optionSet: { + options: optionSet.options.map(option => ({ + name: option.displayName, + code: option.code, + })), + }, + }; + return acc; + }, {}) : undefined), + [optionSets], + ); + + const programMetadata = useMemo(() => { + if (!program || !derivedDataElementValues || !optionSetDictionary) { + return undefined; } - }, [refetch, programId]); - return { error, programMetadata: !loading && data?.programData ? data.programData : undefined }; + const dataElementDictionary = derivedDataElementValues.dataElementDictionary; + + return { + programStages: program.programStages.map(stage => ({ + id: stage.id, + repeatable: stage.repeatable, + hideDueDate: stage.hideDueDate, + enableUserAssignment: stage.enableUserAssignment, + programStageDataElements: stage.programStageDataElements + .map((programStageDataElement) => { + const dataElement = dataElementDictionary[programStageDataElement.dataElementId]; + return { + displayInReports: programStageDataElement.displayInReports, + dataElement: { + id: dataElement.id, + valueType: dataElement.valueType, + displayName: dataElement.displayName, + displayFormName: dataElement.displayFormName, + optionSet: dataElement.optionSetValue ? optionSetDictionary[dataElement.optionSet.id] : {}, + }, + }; + }), + })), + }; + }, [program, derivedDataElementValues, optionSetDictionary]); + + + return { + error: (isError || dataElementsError || optionSetsError) && { programId }, + programMetadata: (isLoading || loadingDataElements || loadingOptionSets) ? undefined : programMetadata, + }; }; diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramStages.js b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramStages.js index 7d692e9867..a746dfe9e6 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramStages.js +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/hooks/useProgramStages.js @@ -11,7 +11,7 @@ export const useProgramStages = (program: Program, programStages?: Array { const { id, name, icon, stageForm } = item; - const { hideDueDate, programStageDataElements, repeatable } = programStages.find(p => p.id === id) || {}; + const { hideDueDate, programStageDataElements, repeatable, enableUserAssignment } = programStages.find(p => p.id === id) || {}; if (!programStageDataElements) { log.error(errorCreator(i18n.t('Program stage not found'))(id)); } else { @@ -21,6 +21,7 @@ export const useProgramStages = (program: Program, programStages?: Array { const { displayInReports, dataElement } = currentStageData; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/Stage.component.js b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/Stage.component.js index 77728e6dc8..87815c565c 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/Stage.component.js +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/Stage.component.js @@ -30,7 +30,7 @@ const hideProgramStage = (ruleEffects, stageId) => ( export const StagePlain = ({ stage, events, classes, className, onCreateNew, ruleEffects, ...passOnProps }: Props) => { const [open, setOpenStatus] = useState(true); - const { id, name, icon, description, dataElements, hideDueDate, repeatable } = stage; + const { id, name, icon, description, dataElements, hideDueDate, repeatable, enableUserAssignment } = stage; const hiddenProgramStage = hideProgramStage(ruleEffects, id); return ( @@ -57,6 +57,7 @@ export const StagePlain = ({ stage, events, classes, className, onCreateNew, rul dataElements={dataElements} hideDueDate={hideDueDate} repeatable={repeatable} + enableUserAssignment={enableUserAssignment} onCreateNew={onCreateNew} hiddenProgramStage={hiddenProgramStage} {...passOnProps} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.js b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.js index 86a4cad8bb..055befd20d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.js +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.js @@ -66,6 +66,7 @@ const StageDetailPlain = (props: Props) => { dataElements, hideDueDate = false, repeatable = false, + enableUserAssignment = false, onEventClick, onViewAll, onCreateNew, @@ -76,7 +77,7 @@ const StageDetailPlain = (props: Props) => { sortDirection: SORT_DIRECTION.DESC, }; const { stage } = getProgramAndStageForProgram(programId, stageId); - const headerColumns = useComputeHeaderColumn(dataElements, hideDueDate, stage?.stageForm); + const headerColumns = useComputeHeaderColumn(dataElements, hideDueDate, enableUserAssignment, stage?.stageForm); const { loading, value: dataSource, error } = useComputeDataFromEvent(dataElements, events); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.js b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.js index 25e9033688..ee914f4e0f 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.js +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/useEventList.js @@ -20,10 +20,11 @@ import { SORT_DIRECTION, MULIT_TEXT_WITH_NO_OPTIONS_SET } from './constants'; import { isNotValidOptionSet } from '../../../../../../utils/isNotValidOptionSet'; import { useOrgUnitNames } from '../../../../../../metadataRetrieval/orgUnitName'; -const baseKeys = [{ id: 'status' }, { id: 'occurredAt' }, { id: 'orgUnitName' }, { id: 'scheduledAt' }, { id: 'comments' }]; +const baseKeys = [{ id: 'status' }, { id: 'occurredAt' }, { id: 'assignedUser' }, { id: 'orgUnitName' }, { id: 'scheduledAt' }, { id: 'comments' }]; const basedFieldTypes = [ { type: dataElementTypes.STATUS, resolveValue: convertStatusForView }, { type: dataElementTypes.DATE }, + { type: 'ASSIGNEE' }, { type: dataElementTypes.TEXT, resolveValue: convertOrgUnitForView }, { type: dataElementTypes.DATE }, { type: dataElementTypes.UNKNOWN, resolveValue: convertCommentForView }, @@ -31,6 +32,7 @@ const basedFieldTypes = [ const getBaseColumnHeaders = props => [ { header: i18n.t('Status'), sortDirection: SORT_DIRECTION.DEFAULT, isPredefined: true }, { header: props.formFoundation.getLabel('occurredAt'), sortDirection: SORT_DIRECTION.DEFAULT, isPredefined: true }, + { header: i18n.t('Assigned to'), sortDirection: SORT_DIRECTION.DEFAULT, isPredefined: true }, { header: i18n.t('Registering unit'), sortDirection: SORT_DIRECTION.DEFAULT, isPredefined: true }, { header: props.formFoundation.getLabel('scheduledAt'), sortDirection: SORT_DIRECTION.DEFAULT, isPredefined: true }, { header: '', sortDirection: null, isPredefined: true }, @@ -118,7 +120,7 @@ const useComputeDataFromEvent = (dataElements: Array, events: }; -const useComputeHeaderColumn = (dataElements: Array, hideDueDate: boolean, formFoundation: Object) => { +const useComputeHeaderColumn = (dataElements: Array, hideDueDate: boolean, enableUserAssignment: boolean, formFoundation: Object) => { const headerColumns = useMemo(() => { const dataElementHeaders = dataElements.reduce((acc, currDataElement) => { const { id, name, formName, type, optionSet } = currDataElement; @@ -132,9 +134,10 @@ const useComputeHeaderColumn = (dataElements: Array, hideDueDa return acc; }, []); return [ - ...getBaseColumns({ formFoundation }).filter(col => (hideDueDate ? col.id !== 'scheduledAt' : true)), + ...getBaseColumns({ formFoundation }) + .filter(col => (enableUserAssignment || col.id !== 'assignedUser') && (!hideDueDate || col.id !== 'scheduledAt')), ...dataElementHeaders]; - }, [dataElements, hideDueDate, formFoundation]); + }, [dataElements, hideDueDate, enableUserAssignment, formFoundation]); return headerColumns; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.js b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.js index a5edeeab75..31d8c79dea 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.js +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.js @@ -7,6 +7,7 @@ import type { StageDataElement, StageCommonProps } from '../../../types/common.t eventName: string, hideDueDate?: boolean, repeatable?: boolean, + enableUserAssignment?: boolean, stageId: string, hiddenProgramStage?: boolean, ...CssClasses, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/types/common.types.js b/src/core_modules/capture-core/components/WidgetStagesAndEvents/types/common.types.js index 1dc3709200..ef1a90c811 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/types/common.types.js +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/types/common.types.js @@ -21,6 +21,7 @@ export type Stage = { description?: ?string, icon?: Icon, dataElements: Array, + enableUserAssignment: boolean, hideDueDate?: boolean, repeatable?: boolean } diff --git a/src/core_modules/capture-core/converters/serverToClient.js b/src/core_modules/capture-core/converters/serverToClient.js index 897a4cc13e..288948461e 100644 --- a/src/core_modules/capture-core/converters/serverToClient.js +++ b/src/core_modules/capture-core/converters/serverToClient.js @@ -11,6 +11,15 @@ function convertTime(d2Value: string) { return parseData.momentTime; } +const convertAssignedUserToClient = (assignedUser?: ApiAssignedUser) => + ((assignedUser && assignedUser.uid) ? { + id: assignedUser.uid, + name: assignedUser.displayName, + username: assignedUser.username, + firstName: assignedUser.firstName, + surname: assignedUser.surname, + } : null); + const optionSetConvertersForType = { [dataElementTypes.NUMBER]: parseNumber, [dataElementTypes.INTEGER]: parseNumber, @@ -51,6 +60,7 @@ const valueConvertersForType = { return { latitude: arr[1], longitude: arr[0] }; }, [dataElementTypes.POLYGON]: () => 'Polygon', + [dataElementTypes.ASSIGNEE]: convertAssignedUserToClient, }; export function convertValue(value: any, type: $Keys) { diff --git a/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js b/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js index 1e3f62f036..207918b8cb 100644 --- a/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js +++ b/src/core_modules/capture-core/events/mainConverters/mainEventClientToServerConverter.js @@ -1,5 +1,5 @@ // @flow -import { convertClientToServer } from '../../converters'; +import { convertClientToServer, convertAssigneeToServer } from '../../converters'; import { convertMainEvent } from './mainEventConverter'; import { dataElementTypes } from '../../metaData'; import { convertEventAttributeOptions } from '../convertEventAttributeOptions'; @@ -26,7 +26,7 @@ export function convertMainEventClientToServer(event: Object) { convertedValue = convertClientToServer(value, dataElementTypes.DATE); break; case 'assignee': - convertedValue = value && ({ uid: value.id }); + convertedValue = value && convertAssigneeToServer(value); break; default: convertedValue = value; diff --git a/src/core_modules/capture-core/utils/cachedDataHooks/useDataElementsFromIndexedDB.js b/src/core_modules/capture-core/utils/cachedDataHooks/useDataElementsFromIndexedDB.js new file mode 100644 index 0000000000..dbbf4f83c3 --- /dev/null +++ b/src/core_modules/capture-core/utils/cachedDataHooks/useDataElementsFromIndexedDB.js @@ -0,0 +1,32 @@ +// @flow +import type { UseQueryOptions } from 'react-query'; +import { userStores, getUserStorageController } from '../../storageControllers'; +import { useIndexedDBQuery } from '../reactQueryHelpers'; +import type { CachedDataElement } from '../../storageControllers/'; + +export const useDataElementsFromIndexedDB = (queryKey: Array, dataElementIds: ?Set, queryOptions?: UseQueryOptions<>): { + dataElements: ?Array, + isLoading: boolean, + isError: boolean, +} => { + const storageController = getUserStorageController(); + const { enabled = !!dataElementIds } = queryOptions ?? {}; + + const { data, isLoading, isError } = useIndexedDBQuery( + ['dataElements', ...queryKey], + () => storageController.getAll( + userStores.DATA_ELEMENTS, { + // $FlowIgnore - the enabled prop guarantees that dataElementIds will be defined + predicate: dataElement => dataElementIds.has(dataElement.id), + }, + ), { + enabled, + }, + ); + + return { + dataElements: data, + isLoading, + isError, + }; +}; diff --git a/src/core_modules/capture-core/utils/cachedDataHooks/useOptionSetsFromIndexedDB.js b/src/core_modules/capture-core/utils/cachedDataHooks/useOptionSetsFromIndexedDB.js new file mode 100644 index 0000000000..f0480779ee --- /dev/null +++ b/src/core_modules/capture-core/utils/cachedDataHooks/useOptionSetsFromIndexedDB.js @@ -0,0 +1,32 @@ +// @flow +import type { UseQueryOptions } from 'react-query'; +import { userStores, getUserStorageController } from '../../storageControllers'; +import { useIndexedDBQuery } from '../reactQueryHelpers'; +import type { CachedOptionSet } from '../../storageControllers/'; + +export const useOptionSetsFromIndexedDB = (queryKey: Array, optionSetIds: ?Set, queryOptions?: UseQueryOptions<>): { + optionSets: ?Array, + isLoading: boolean, + isError: boolean, +} => { + const storageController = getUserStorageController(); + const { enabled = !!optionSetIds } = queryOptions ?? {}; + + const { data, isLoading, isError } = useIndexedDBQuery( + ['optionSets', ...queryKey], + () => storageController.getAll( + userStores.OPTION_SETS, { + // $FlowIgnore - the enabled prop guarantees that optionSetIds will be defined + predicate: optionSet => optionSetIds.has(optionSet.id), + }, + ), { + enabled, + }, + ); + + return { + optionSets: data, + isLoading, + isError, + }; +}; diff --git a/src/core_modules/capture-core/utils/cachedDataHooks/useProgramFromIndexedDB.js b/src/core_modules/capture-core/utils/cachedDataHooks/useProgramFromIndexedDB.js index c8a73f7838..d5a1c8a02e 100644 --- a/src/core_modules/capture-core/utils/cachedDataHooks/useProgramFromIndexedDB.js +++ b/src/core_modules/capture-core/utils/cachedDataHooks/useProgramFromIndexedDB.js @@ -4,9 +4,9 @@ import { userStores, getUserStorageController } from '../../storageControllers'; import { useIndexedDBQuery } from '../reactQueryHelpers'; -export const useProgramFromIndexedDB = (programId: ?string, QueryOptions?: UseQueryOptions<>) => { +export const useProgramFromIndexedDB = (programId: ?string, queryOptions?: UseQueryOptions<>) => { const storageController = getUserStorageController(); - const { enabled = true } = QueryOptions ?? {}; + const { enabled = true } = queryOptions ?? {}; const { data, isLoading, isError } = useIndexedDBQuery( // $FlowFixMe - only gets called when programId is defined because of enabled diff --git a/src/core_modules/capture-core/utils/reactQueryHelpers/query/useMetadataQuery.js b/src/core_modules/capture-core/utils/reactQueryHelpers/query/useMetadataQuery.js index ee31061b8c..2452405547 100644 --- a/src/core_modules/capture-core/utils/reactQueryHelpers/query/useMetadataQuery.js +++ b/src/core_modules/capture-core/utils/reactQueryHelpers/query/useMetadataQuery.js @@ -5,7 +5,7 @@ import { useDataEngine, type ResourceQuery } from '@dhis2/app-runtime'; import type { QueryFunction, UseQueryOptions } from 'react-query'; import { IndexedDBError } from '../../../../capture-core-utils/storage/IndexedDBError/IndexedDBError'; import type { Result } from './useMetadataQuery.types'; -import { ReactQueryAppNamespace } from '../reactQueryHelpers.const'; +import { ReactQueryAppNamespace, IndexedDBNamespace } from '../reactQueryHelpers.const'; const throwErrorForIndexedDB = (error) => { if (error instanceof IndexedDBError) { @@ -43,7 +43,7 @@ export const useIndexedDBQuery = ( queryFn: QueryFunction, queryOptions?: UseQueryOptions, ): Result => - useAsyncMetadata(queryKey, queryFn, { + useAsyncMetadata([IndexedDBNamespace, ...queryKey], queryFn, { cacheTime: 0, ...queryOptions, onError: (error) => { diff --git a/src/core_modules/capture-core/utils/reactQueryHelpers/reactQueryHelpers.const.js b/src/core_modules/capture-core/utils/reactQueryHelpers/reactQueryHelpers.const.js index 2659b304ba..5256c91b69 100644 --- a/src/core_modules/capture-core/utils/reactQueryHelpers/reactQueryHelpers.const.js +++ b/src/core_modules/capture-core/utils/reactQueryHelpers/reactQueryHelpers.const.js @@ -1,4 +1,4 @@ - // @flow export const ReactQueryAppNamespace = 'capture'; +export const IndexedDBNamespace = 'indexedDB';