From 9e1a5ce34a4dea0b0623ced6ad91399ff3852cfe Mon Sep 17 00:00:00 2001 From: chauthutran Date: Wed, 8 Jul 2026 20:26:23 +0900 Subject: [PATCH] feat: filter category options by user's data capture and maintenance organisation units fix: fix some errors in unit tests fix: show the category combo selector when only one category option is available test: address SonarCloud recommendation in tests fix: prevent double scrollbar in category selection menu fix: prevent double scrollbar in category selection menu --- i18n/en.pot | 11 +++- src/app-context/app-provider.jsx | 11 ++-- .../attribute-combo-select.jsx | 5 +- .../attribute-combo-select.module.css | 4 +- .../single-category-select.module.css | 1 - .../org-unit-select/org-unit-select.jsx | 10 +++- .../org-unit-select/org-unit-select.test.jsx | 13 ++--- src/utils/app-provider-util.js | 51 ++++++++++++++++--- src/utils/selection-provider-util.js | 20 ++++---- src/workflow-context/workflow-provider.jsx | 16 +++++- 10 files changed, 106 insertions(+), 36 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f1ff05e9..b74af266 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-05-20T02:12:18.107Z\n" -"PO-Revision-Date: 2026-05-20T02:12:18.108Z\n" +"POT-Creation-Date: 2026-07-21T00:39:16.884Z\n" +"PO-Revision-Date: 2026-07-21T00:39:16.884Z\n" msgid "Not authorized" msgstr "Not authorized" @@ -343,5 +343,12 @@ msgstr "" "Please verify the workflow configuration or select a different workflow " "that includes data sets." +msgid "" +"Workflow \"{{ workflowName }}\" and period \"{{periodName}}\" does not " +"contain any category combos." +msgstr "" +"Workflow \"{{ workflowName }}\" and period \"{{periodName}}\" does not " +"contain any category combos." + msgid "Retry loading approval data" msgstr "Retry loading approval data" diff --git a/src/app-context/app-provider.jsx b/src/app-context/app-provider.jsx index f6a0b537..c4628e20 100644 --- a/src/app-context/app-provider.jsx +++ b/src/app-context/app-provider.jsx @@ -139,11 +139,12 @@ const AppProvider = ({ children }) => { authorities, organisationUnits, dataApprovalWorkflows, - metadata: normalizeMetadata( - categoryCombos, - categories, - categoryOptionCombos - ), + metadata: normalizeMetadata({ + originalCatCombos: categoryCombos, + originalCategories: categories, + originalCategoryOptionCombos: categoryOptionCombos, + organisationUnits, + }), } return ( diff --git a/src/top-bar/attribute-combo-select/attribute-combo-select.jsx b/src/top-bar/attribute-combo-select/attribute-combo-select.jsx index 0c0ac61d..a4aa978c 100644 --- a/src/top-bar/attribute-combo-select/attribute-combo-select.jsx +++ b/src/top-bar/attribute-combo-select/attribute-combo-select.jsx @@ -76,8 +76,9 @@ const AttributeComboSelect = () => { style={{ height: attributeCombos?.length == 1 - ? '270px' - : '350px', + ? 270 + : 350, + overflowY: 'auto', }} > {/* Only show Category Combo dropdown when there are more than one categoryCombo in the list */} diff --git a/src/top-bar/attribute-combo-select/attribute-combo-select.module.css b/src/top-bar/attribute-combo-select/attribute-combo-select.module.css index 830be518..8d4a370b 100644 --- a/src/top-bar/attribute-combo-select/attribute-combo-select.module.css +++ b/src/top-bar/attribute-combo-select/attribute-combo-select.module.css @@ -1,7 +1,7 @@ .menu { - height: 330px; width: 400px; - overflow-y: auto; + display: flex; + flex-direction: column; } .bordered { diff --git a/src/top-bar/attribute-combo-select/single-category-select.module.css b/src/top-bar/attribute-combo-select/single-category-select.module.css index 39b46b23..693ff3b3 100644 --- a/src/top-bar/attribute-combo-select/single-category-select.module.css +++ b/src/top-bar/attribute-combo-select/single-category-select.module.css @@ -9,7 +9,6 @@ } .menu { - max-height: 70vh; overflow-y: auto; } diff --git a/src/top-bar/org-unit-select/org-unit-select.jsx b/src/top-bar/org-unit-select/org-unit-select.jsx index ad712a8d..dc09afba 100644 --- a/src/top-bar/org-unit-select/org-unit-select.jsx +++ b/src/top-bar/org-unit-select/org-unit-select.jsx @@ -20,6 +20,7 @@ const OrgUnitSelect = () => { openedSelect, setOpenedSelect, attributeOptionCombo, + attributeCombos, } = useSelectionContext() const open = openedSelect === ORG_UNIT const value = orgUnit?.displayName @@ -50,7 +51,14 @@ const OrgUnitSelect = () => { placeholder={i18n.t('Choose an organisation unit')} value={requiredValuesMessage === null ? value : ''} open={open} - disabled={!(workflow?.id && period?.id && attributeOptionCombo?.id)} + disabled={ + !( + workflow?.id && + period?.id && + attributeOptionCombo?.id && + attributeCombos?.length > 0 + ) + } onOpen={() => setOpenedSelect(ORG_UNIT)} onClose={() => setOpenedSelect('')} requiredValuesMessage={getRequiredValuesMessage()} diff --git a/src/top-bar/org-unit-select/org-unit-select.test.jsx b/src/top-bar/org-unit-select/org-unit-select.test.jsx index 54f76ced..430fdb2a 100644 --- a/src/top-bar/org-unit-select/org-unit-select.test.jsx +++ b/src/top-bar/org-unit-select/org-unit-select.test.jsx @@ -62,6 +62,7 @@ describe('', () => { period: { id: '20120402' }, orgUnit: {}, attributeOptionCombo: { id: '1234' }, + attributeCombos: [{ id: '1' }], openedSelect: '', selectWorkflow: () => {}, setOpenedSelect: () => {}, @@ -124,6 +125,7 @@ describe('', () => { }, orgUnit: {}, attributeOptionCombo: { id: '1234' }, + attributeCombos: [{ id: '1' }], openedSelect: '', selectWorkflow: () => {}, setOpenedSelect: () => {}, @@ -179,6 +181,7 @@ describe('', () => { attributeOptionCombo: { id: 'wertyuiopas', }, + attributeCombos: [{ id: '1' }], openedSelect: '', selectWorkflow: () => {}, setOpenedSelect: () => {}, @@ -218,16 +221,14 @@ describe('', () => { }, orgUnit: {}, attributeOptionCombo: { id: '1234' }, + attributeCombos: [{ id: '1' }], openedSelect: '', selectWorkflow: () => {}, setOpenedSelect, })) - shallow() - .find(ContextSelect) - .dive() - .find('button') - .simulate('click') + const wrapper = shallow() + wrapper.find(ContextSelect).prop('onOpen')() expect(setOpenedSelect).toHaveBeenCalledTimes(1) expect(setOpenedSelect).toHaveBeenCalledWith(ORG_UNIT) @@ -265,7 +266,7 @@ describe('', () => { ) - await waitFor(() => screen.getByText('Sierra Leone')) + await screen.findByText('Sierra Leone') await userEvent.click(screen.getByText('Sierra Leone')) expect(selectOrgUnit).toHaveBeenCalledTimes(1) diff --git a/src/utils/app-provider-util.js b/src/utils/app-provider-util.js index 0616ce6a..818d8fa4 100644 --- a/src/utils/app-provider-util.js +++ b/src/utils/app-provider-util.js @@ -1,12 +1,16 @@ import { cloneJSON, sortList } from './array-utils.js' -export const normalizeMetadata = ( +export const normalizeMetadata = ({ originalCatCombos, originalCategories, - originalCategoryOptionCombos -) => { + originalCategoryOptionCombos, + organisationUnits, +}) => { const categoryCombos = normalizeCatCombos(originalCatCombos) - const categoryMap = normalizeCategoriesAndOptions(originalCategories) + const categoryMap = normalizeCategoriesAndOptions( + originalCategories, + organisationUnits + ) const metadata = { categoryCombos, @@ -73,20 +77,31 @@ const normalizeCatCombos = (originalCatCombos) => { },... }, */ -const normalizeCategoriesAndOptions = (originalCategories) => { +const normalizeCategoriesAndOptions = ( + originalCategories, + organisationUnits +) => { const normalized = { categories: {}, categoryOptions: {} } for (const category of originalCategories) { // Map each unique categoryOption by ID const options = cloneJSON(category.categoryOptions || []) + const validOptions = [] for (const option of options) { const found = normalized.categoryOptions[option.id] if (!found) { - normalized.categoryOptions[option.id] = option + const isAssigned = isOptionAssignedToUserOrgUnits( + option, + organisationUnits + ) + if (isAssigned) { + normalized.categoryOptions[option.id] = option + validOptions.push(option) + } } } // Map category by ID - const categoryOptionIds = options.map((item) => item.id) + const categoryOptionIds = validOptions.map((item) => item.id) delete category.categoryOptions normalized.categories[category.id] = { ...category, @@ -156,3 +171,25 @@ const omitField = (obj, keyToRemove) => { const { [keyToRemove]: _, ...rest } = obj return rest } + +const isChildOfAnyParent = (userOrgUnitPaths, catOptionOrgUnitPath) => + userOrgUnitPaths.some( + (userOrgUnitPath) => + catOptionOrgUnitPath === userOrgUnitPath || + catOptionOrgUnitPath.startsWith(userOrgUnitPath + '/') + ) + +const isOptionAssignedToUserOrgUnits = ( + categoryOption, + userOrganisationUnits +) => { + // by default, + if (!categoryOption?.organisationUnits?.length) { + return true + } + + const userOrgUnitPaths = userOrganisationUnits.map((ou) => ou.path) + return categoryOption.organisationUnits.some((catOptionOrgUnit) => + isChildOfAnyParent(userOrgUnitPaths, catOptionOrgUnit.path) + ) +} diff --git a/src/utils/selection-provider-util.js b/src/utils/selection-provider-util.js index 0acb7c39..4f405285 100644 --- a/src/utils/selection-provider-util.js +++ b/src/utils/selection-provider-util.js @@ -120,7 +120,7 @@ const shouldShowAttributeCombo = ({ if ( attributeCombos.length == 1 && singleCategoryCombo.categoryIds?.length === 1 && - firstCategory.categoryOptionIds?.length <= 1 + firstCategory.categoryOptionIds?.length === 0 ) { return false } @@ -144,14 +144,7 @@ export const getAttributeComboState = ({ let _attributeCombo = attributeCombo let _attributeOptionCombo = null - const isVisible = shouldShowAttributeCombo({ - workflow, - period, - attributeCombos: _attributeCombos, - attributeCombo: _attributeCombo, - metadata, - calendar, - }) + let attributeComboValue = i18n.t('0 selections') const processCategoryOptions = (metadata, attributeOptionCombo) => { @@ -232,6 +225,15 @@ export const getAttributeComboState = ({ } } + const isVisible = shouldShowAttributeCombo({ + workflow, + period, + attributeCombos: _attributeCombos, + selectedAttrCombo: _attributeCombo, + metadata, + calendar, + }) + return { attributeCombos: _attributeCombos, attributeCombo: _attributeCombo, diff --git a/src/workflow-context/workflow-provider.jsx b/src/workflow-context/workflow-provider.jsx index e4bf36ae..ab8c5d67 100644 --- a/src/workflow-context/workflow-provider.jsx +++ b/src/workflow-context/workflow-provider.jsx @@ -19,7 +19,7 @@ const query = { } const WorkflowProvider = ({ children }) => { - const { workflow, period, orgUnit, attributeOptionCombo } = + const { workflow, period, orgUnit, attributeOptionCombo, attributeCombos } = useSelectionContext() const { fetching, error, data, called, refetch } = useDataQuery(query, { lazy: true, @@ -55,6 +55,20 @@ const WorkflowProvider = ({ children }) => { ) } + if (workflow && period && attributeCombos?.length === 0) { + return ( + + {i18n.t( + 'Workflow "{{ workflowName }}" and period "{{periodName}}" does not contain any category combos.', + { + workflowName: workflow?.displayName, + periodName: period?.displayName, + } + )} + + ) + } + // Handle missing required selections if (!workflow || !period || !orgUnit || !attributeOptionCombo) { return null