From 032856045cd0be06056c35c14fe238796d92848a Mon Sep 17 00:00:00 2001 From: Jan-Gerke Salomon Date: Thu, 7 Sep 2023 17:24:49 +0200 Subject: [PATCH] feat(de form): make fields with values from api dynamic --- .ls-lint.yml | 2 +- i18n/en.pot | 77 +++--- src/App.tsx | 14 ++ .../SearchableSingleSelect.module.css | 2 +- .../SearchableSingleSelect.tsx | 45 ++-- src/components/index.tsx | 2 +- .../AggregationLevelMultiSelect.module.css | 32 +++ .../AggregationLevelMultiSelect.tsx | 97 ++++++++ .../AggregationLevelMultiSelect/index.ts | 1 + .../AggregationLevelMultiSelect/types.ts | 12 + .../useOptionsQuery.ts | 42 ++++ .../CategoryComboSelect.tsx | 40 +++ .../CategoryComboSelect/index.ts | 0 .../CategoryComboSelect/types.ts | 2 +- .../useInitialOptionQuery.ts | 4 +- .../CategoryComboSelect/useOptionsQuery.ts | 2 +- .../LegendSetTransfer/LegendSetTransfer.tsx | 150 ++++++++++++ .../LegendSetTransfer/index.ts | 1 + .../LegendSetTransfer/types.ts | 5 + .../useInitialOptionQuery.ts | 42 ++++ .../LegendSetTransfer/useOptionsQuery.ts | 77 ++++++ .../ModelSingleSelect/ModelSingleSelect.tsx} | 92 +++++-- .../ModelSingleSelect/index.ts | 1 + .../OptionSetSelect/OptionSetSelect.tsx | 40 +++ .../OptionSetSelect/index.ts | 1 + .../OptionSetSelect/types.ts | 5 + .../OptionSetSelect/useInitialOptionQuery.ts | 37 +++ .../OptionSetSelect/useOptionsQuery.ts | 76 ++++++ src/components/metadataFormControls/index.ts | 4 + src/components/metadataSelects/index.ts | 1 - src/lib/debounce/index.ts | 1 + src/lib/debounce/useDebouncedState.ts | 52 ++++ src/lib/index.ts | 1 + src/pages/dataElements/New.tsx | 18 +- .../form/DataElementFormFields.tsx | 136 +---------- .../form/LegendSetTransferField.tsx | 78 ------ ...eld.module.css => customFields.module.css} | 4 + src/pages/dataElements/form/customFields.tsx | 231 ++++++++++++++++-- src/pages/dataElements/form/hooks.ts | 108 +------- 39 files changed, 1116 insertions(+), 419 deletions(-) create mode 100644 src/components/metadataFormControls/AggregationLevelMultiSelect/AggregationLevelMultiSelect.module.css create mode 100644 src/components/metadataFormControls/AggregationLevelMultiSelect/AggregationLevelMultiSelect.tsx create mode 100644 src/components/metadataFormControls/AggregationLevelMultiSelect/index.ts create mode 100644 src/components/metadataFormControls/AggregationLevelMultiSelect/types.ts create mode 100644 src/components/metadataFormControls/AggregationLevelMultiSelect/useOptionsQuery.ts create mode 100644 src/components/metadataFormControls/CategoryComboSelect/CategoryComboSelect.tsx rename src/components/{metadataSelects => metadataFormControls}/CategoryComboSelect/index.ts (100%) rename src/components/{metadataSelects => metadataFormControls}/CategoryComboSelect/types.ts (73%) rename src/components/{metadataSelects => metadataFormControls}/CategoryComboSelect/useInitialOptionQuery.ts (89%) rename src/components/{metadataSelects => metadataFormControls}/CategoryComboSelect/useOptionsQuery.ts (97%) create mode 100644 src/components/metadataFormControls/LegendSetTransfer/LegendSetTransfer.tsx create mode 100644 src/components/metadataFormControls/LegendSetTransfer/index.ts create mode 100644 src/components/metadataFormControls/LegendSetTransfer/types.ts create mode 100644 src/components/metadataFormControls/LegendSetTransfer/useInitialOptionQuery.ts create mode 100644 src/components/metadataFormControls/LegendSetTransfer/useOptionsQuery.ts rename src/components/{metadataSelects/CategoryComboSelect/CategoryComboSelect.tsx => metadataFormControls/ModelSingleSelect/ModelSingleSelect.tsx} (61%) create mode 100644 src/components/metadataFormControls/ModelSingleSelect/index.ts create mode 100644 src/components/metadataFormControls/OptionSetSelect/OptionSetSelect.tsx create mode 100644 src/components/metadataFormControls/OptionSetSelect/index.ts create mode 100644 src/components/metadataFormControls/OptionSetSelect/types.ts create mode 100644 src/components/metadataFormControls/OptionSetSelect/useInitialOptionQuery.ts create mode 100644 src/components/metadataFormControls/OptionSetSelect/useOptionsQuery.ts create mode 100644 src/components/metadataFormControls/index.ts delete mode 100644 src/components/metadataSelects/index.ts create mode 100644 src/lib/debounce/index.ts create mode 100644 src/lib/debounce/useDebouncedState.ts delete mode 100644 src/pages/dataElements/form/LegendSetTransferField.tsx rename src/pages/dataElements/form/{LegendSetTransferField.module.css => customFields.module.css} (68%) diff --git a/.ls-lint.yml b/.ls-lint.yml index b6ebd32c..d396f74b 100644 --- a/.ls-lint.yml +++ b/.ls-lint.yml @@ -8,7 +8,7 @@ ls: .spec.tsx: PascalCase | camelCase .spec.ts: PascalCase | camelCase .tsx: PascalCase | camelCase - .module.css: PascalCase + .module.css: PascalCase | camelCase .d.ts: kebab-case cypress: diff --git a/i18n/en.pot b/i18n/en.pot index 9a5486d1..bdbe550f 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: 2023-09-05T13:18:37.488Z\n" -"PO-Revision-Date: 2023-09-05T13:18:37.488Z\n" +"POT-Creation-Date: 2023-09-11T06:58:45.433Z\n" +"PO-Revision-Date: 2023-09-11T06:58:45.433Z\n" msgid "schemas" msgstr "schemas" @@ -54,8 +54,8 @@ msgstr "Retry" msgid "All" msgstr "All" -msgid "Category combo" -msgstr "Category combo" +msgid "Filter options" +msgstr "Filter options" msgid "Failed to load {{label}}" msgstr "Failed to load {{label}}" @@ -63,9 +63,21 @@ msgstr "Failed to load {{label}}" msgid "Failed to load" msgstr "Failed to load" +msgid "Aggregation level(s)" +msgstr "Aggregation level(s)" + +msgid "Category combo" +msgstr "Category combo" + msgid "None" msgstr "None" +msgid "Filter legend sets" +msgstr "Filter legend sets" + +msgid "Option set" +msgstr "Option set" + msgid "Actions" msgstr "Actions" @@ -303,9 +315,6 @@ msgstr "Attribute" msgid "Attributes" msgstr "Attributes" -msgid "Option set" -msgstr "Option set" - msgid "Option sets" msgstr "Option sets" @@ -609,21 +618,6 @@ msgstr "Disaggregation and Option sets" msgid "Set up disaggregation and predefined options." msgstr "Set up disaggregation and predefined options." -msgid "Category combination (required)" -msgstr "Category combination (required)" - -msgid "Choose how this data element is disaggregated" -msgstr "Choose how this data element is disaggregated" - -msgid "Choose a set of predefined options for data entry" -msgstr "Choose a set of predefined options for data entry" - -msgid "Option set comment" -msgstr "Option set comment" - -msgid "Choose a set of predefined comment for data entry" -msgstr "Choose a set of predefined comment for data entry" - msgid "LegendSet" msgstr "LegendSet" @@ -637,27 +631,12 @@ msgstr "" msgid "Aggregation levels" msgstr "Aggregation levels" -msgid "Aggregation level(s)" -msgstr "Aggregation level(s)" - msgid "Custom attributes" msgstr "Custom attributes" msgid "Custom fields for your DHIS2 instance" msgstr "Custom fields for your DHIS2 instance" -msgid "Selected legends" -msgstr "Selected legends" - -msgid "Refresh list" -msgstr "Refresh list" - -msgid "Add new" -msgstr "Add new" - -msgid "Filter available legends" -msgstr "Filter available legends" - msgid "Color and icon" msgstr "Color and icon" @@ -674,6 +653,15 @@ msgstr "Domain (required)" msgid "A data element can either be aggregated or tracked data." msgstr "A data element can either be aggregated or tracked data." +msgid "Selected legends" +msgstr "Selected legends" + +msgid "Refresh list" +msgstr "Refresh list" + +msgid "Add new" +msgstr "Add new" + msgid "Value type (required)" msgstr "Value type (required)" @@ -686,6 +674,21 @@ msgstr "Aggretation type (required)" msgid "The default way to aggregate this data element in analytics." msgstr "The default way to aggregate this data element in analytics." +msgid "Category combination (required)" +msgstr "Category combination (required)" + +msgid "Choose how this data element is disaggregated" +msgstr "Choose how this data element is disaggregated" + +msgid "Choose a set of predefined options for data entry" +msgstr "Choose a set of predefined options for data entry" + +msgid "Option set comment" +msgstr "Option set comment" + +msgid "Choose a set of predefined comment for data entry" +msgstr "Choose a set of predefined comment for data entry" + msgid "Metadata management" msgstr "Metadata management" diff --git a/src/App.tsx b/src/App.tsx index 696d546a..11cea367 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,20 @@ import React from 'react' import { AppWrapper, ConfiguredRouter } from './app/' +// @TODO: Find a solution for these! +const consoleWarnOrig = console.warn +console.warn = (...args) => { + const msg = args[0] + + if ( + !msg.startsWith('The query should be static') && + !msg.startsWith('Data queries with paging=false are deprecated') && + !msg.startsWith('StyleSheet: illegal rule:') + ) { + consoleWarnOrig(...args) + } +} + const MyApp = () => ( diff --git a/src/components/SearchableSingleSelect/SearchableSingleSelect.module.css b/src/components/SearchableSingleSelect/SearchableSingleSelect.module.css index 76ba6d11..80f24c0c 100644 --- a/src/components/SearchableSingleSelect/SearchableSingleSelect.module.css +++ b/src/components/SearchableSingleSelect/SearchableSingleSelect.module.css @@ -43,7 +43,7 @@ position: sticky; top: 0; padding: var(--spacers-dp16); - boxShadow: 0 0 4px rgba(0,0,0,0.4); + box-shadow: 0 0 4px rgba(0,0,0,0.4); background: var(--colors-white); } diff --git a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx index 3b1aaeb9..4d1471e6 100644 --- a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx +++ b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx @@ -5,8 +5,9 @@ import { SingleSelect, SingleSelectOption, } from '@dhis2/ui' -import React, { forwardRef, useCallback, useEffect, useState } from 'react' -import { useDebouncedCallback } from 'use-debounce' +import React, { forwardRef, useEffect, useState } from 'react' +// import { useDebouncedCallback } from 'use-debounce' +import { useDebouncedState } from '../../lib' import classes from './SearchableSingleSelect.module.css' interface Option { @@ -53,41 +54,38 @@ interface SearchableSingleSelectPropTypes { onEndReached: () => void onRetryClick: () => void options: Option[] + placeholder: string showEndLoader: boolean loading: boolean selected?: string error?: string showAllOption?: boolean + onBlur?: () => void + onFocus?: () => void } export const SearchableSingleSelect = ({ - showAllOption, error, loading, + placeholder, + onBlur, onChange, - onFilterChange, onEndReached, + onFilterChange, + onFocus, + onRetryClick, options, selected, + showAllOption, showEndLoader, - onRetryClick, }: SearchableSingleSelectPropTypes) => { const [loadingSpinnerRef, setLoadingSpinnerRef] = useState() - const debouncedOnFilterChange = useDebouncedCallback( - (args) => onFilterChange(args), - 200 - ) - // We want to defer the actual filter value so we don't send a request with - // every key stroke - const [filterValue, _setFilterValue] = useState('') - const setFilterValue = useCallback( - (nextFilterValue: string) => { - _setFilterValue(nextFilterValue) - debouncedOnFilterChange({ value: nextFilterValue }) - }, - [debouncedOnFilterChange] - ) + const { liveValue: filter, setValue: setFilterValue } = + useDebouncedState({ + initialValue: '', + onSetDebouncedValue: (value: string) => onFilterChange({ value }), + }) useEffect(() => { // We don't want to wait for intersections when loading as that can @@ -124,22 +122,25 @@ export const SearchableSingleSelect = ({ // any value to the "selected" prop, as otherwise an error will be thrown selected={hasSelectedInOptionList ? selected : ''} onChange={onChange} - placeholder={i18n.t('Category combo')} + placeholder={placeholder} + onBlur={onBlur} + onFocus={onFocus} >
setFilterValue(value) } + placeholder={i18n.t('Filter options')} />
+
+ + ) +} + +interface AggregationLevelMultiSelectProps { + onChange: ({ selected }: { selected: string[] }) => void + onRetryClick: () => void + inputWidth?: string + placeholder?: string + selected?: string[] + showAllOption?: boolean + onBlur?: () => void + onFocus?: () => void +} + +export const AggregationLevelMultiSelect = forwardRef( + function AggregationLevelMultiSelect( + { + onChange, + inputWidth, + selected, + showAllOption, + placeholder = i18n.t('Aggregation level(s)'), + onBlur, + onFocus, + onRetryClick, + }: AggregationLevelMultiSelectProps, + ref + ) { + const optionsQuery = useOptionsQuery() + const { refetch } = optionsQuery + + useImperativeHandle(ref, () => ({ refetch }), [refetch]) + + const displayOptions = optionsQuery.data?.result || [] + const loading = optionsQuery.fetching || optionsQuery.loading + + return ( + { + onChange({ selected }) + }} + error={!!optionsQuery.error} + selected={loading ? [] : selected} + loading={loading} + onBlur={onBlur} + onFocus={onFocus} + > + {showAllOption && ( + + )} + + {displayOptions.map(({ value, label }) => ( + + ))} + + {optionsQuery.error && ( + + )} + + ) + } +) diff --git a/src/components/metadataFormControls/AggregationLevelMultiSelect/index.ts b/src/components/metadataFormControls/AggregationLevelMultiSelect/index.ts new file mode 100644 index 00000000..db1dcb6a --- /dev/null +++ b/src/components/metadataFormControls/AggregationLevelMultiSelect/index.ts @@ -0,0 +1 @@ +export { AggregationLevelMultiSelect } from './AggregationLevelMultiSelect' diff --git a/src/components/metadataFormControls/AggregationLevelMultiSelect/types.ts b/src/components/metadataFormControls/AggregationLevelMultiSelect/types.ts new file mode 100644 index 00000000..267c8b1c --- /dev/null +++ b/src/components/metadataFormControls/AggregationLevelMultiSelect/types.ts @@ -0,0 +1,12 @@ +import { + OrganisationUnitLevel, + GistCollectionResponse, +} from '../../../types/generated' + +const filterFields = ['id', 'displayName'] as const //(name is translated by default in /gist) +export type FilteredAggregationLevel = Pick< + OrganisationUnitLevel, + (typeof filterFields)[number] +> +export type AggregationLevelQueryResult = + GistCollectionResponse diff --git a/src/components/metadataFormControls/AggregationLevelMultiSelect/useOptionsQuery.ts b/src/components/metadataFormControls/AggregationLevelMultiSelect/useOptionsQuery.ts new file mode 100644 index 00000000..cf738219 --- /dev/null +++ b/src/components/metadataFormControls/AggregationLevelMultiSelect/useOptionsQuery.ts @@ -0,0 +1,42 @@ +import { useDataQuery } from '@dhis2/app-runtime' +import { useMemo } from 'react' +import { OrganisationUnitLevel } from '../../../types/generated' + +type AggregationLevelQueryResult = { + aggregationLevels: { + organisationUnitLevels: OrganisationUnitLevel[] + } +} + +const CATEGORY_COMBOS_QUERY = { + aggregationLevels: { + resource: 'organisationUnitLevels', + params: { + paging: false, + fields: ['id', 'displayName'], + order: ['displayName'], + filter: 'name:ne:default', + }, + }, +} + +export function useOptionsQuery() { + const queryResult = useDataQuery( + CATEGORY_COMBOS_QUERY + ) + const { data } = queryResult + + return useMemo(() => { + const aggregationLevels = data?.aggregationLevels.organisationUnitLevels + const loadedOptions = + aggregationLevels?.map(({ id, displayName }) => ({ + value: id, + label: displayName, + })) || [] + + return { + ...queryResult, + data: { result: loadedOptions }, + } + }, [data?.aggregationLevels.organisationUnitLevels, queryResult]) +} diff --git a/src/components/metadataFormControls/CategoryComboSelect/CategoryComboSelect.tsx b/src/components/metadataFormControls/CategoryComboSelect/CategoryComboSelect.tsx new file mode 100644 index 00000000..c2d1a050 --- /dev/null +++ b/src/components/metadataFormControls/CategoryComboSelect/CategoryComboSelect.tsx @@ -0,0 +1,40 @@ +import i18n from '@dhis2/d2-i18n' +import React, { forwardRef } from 'react' +import { ModelSingleSelect } from '../ModelSingleSelect' +import { useInitialOptionQuery } from './useInitialOptionQuery' +import { useOptionsQuery } from './useOptionsQuery' + +interface CategoryComboSelectProps { + onChange: ({ selected }: { selected: string }) => void + placeholder?: string + selected?: string + showAllOption?: boolean + onBlur?: () => void + onFocus?: () => void +} + +export const CategoryComboSelect = forwardRef(function CategoryComboSelect( + { + onChange, + placeholder = i18n.t('Category combo'), + selected, + showAllOption, + onBlur, + onFocus, + }: CategoryComboSelectProps, + ref +) { + return ( + + ) +}) diff --git a/src/components/metadataSelects/CategoryComboSelect/index.ts b/src/components/metadataFormControls/CategoryComboSelect/index.ts similarity index 100% rename from src/components/metadataSelects/CategoryComboSelect/index.ts rename to src/components/metadataFormControls/CategoryComboSelect/index.ts diff --git a/src/components/metadataSelects/CategoryComboSelect/types.ts b/src/components/metadataFormControls/CategoryComboSelect/types.ts similarity index 73% rename from src/components/metadataSelects/CategoryComboSelect/types.ts rename to src/components/metadataFormControls/CategoryComboSelect/types.ts index a7923433..01007719 100644 --- a/src/components/metadataSelects/CategoryComboSelect/types.ts +++ b/src/components/metadataFormControls/CategoryComboSelect/types.ts @@ -1,6 +1,6 @@ import { CategoryCombo, GistCollectionResponse } from '../../../types/generated' -const filterFields = ['id', 'name'] as const //(name is translated by default in /gist) +const filterFields = ['id', 'displayName'] as const //(name is translated by default in /gist) export type FilteredCategoryCombo = Pick< CategoryCombo, (typeof filterFields)[number] diff --git a/src/components/metadataSelects/CategoryComboSelect/useInitialOptionQuery.ts b/src/components/metadataFormControls/CategoryComboSelect/useInitialOptionQuery.ts similarity index 89% rename from src/components/metadataSelects/CategoryComboSelect/useInitialOptionQuery.ts rename to src/components/metadataFormControls/CategoryComboSelect/useInitialOptionQuery.ts index 58c9de91..ec6c00cd 100644 --- a/src/components/metadataSelects/CategoryComboSelect/useInitialOptionQuery.ts +++ b/src/components/metadataFormControls/CategoryComboSelect/useInitialOptionQuery.ts @@ -12,7 +12,7 @@ const INITIAL_OPTION_QUERY = { resource: 'categoryCombos', id: (variables: Record) => variables.id, params: { - fields: ['id', 'name'], + fields: ['id', 'displayName'], }, }, } @@ -30,7 +30,7 @@ export function useInitialOptionQuery({ variables: { id: selected }, onComplete: (data) => { const categoryCombo = data.categoryCombo - const { id: value, name: label } = categoryCombo + const { id: value, displayName: label } = categoryCombo onComplete({ value, label }) }, }) diff --git a/src/components/metadataSelects/CategoryComboSelect/useOptionsQuery.ts b/src/components/metadataFormControls/CategoryComboSelect/useOptionsQuery.ts similarity index 97% rename from src/components/metadataSelects/CategoryComboSelect/useOptionsQuery.ts rename to src/components/metadataFormControls/CategoryComboSelect/useOptionsQuery.ts index d3c78212..47a2173a 100644 --- a/src/components/metadataSelects/CategoryComboSelect/useOptionsQuery.ts +++ b/src/components/metadataFormControls/CategoryComboSelect/useOptionsQuery.ts @@ -25,7 +25,7 @@ const CATEGORY_COMBOS_QUERY = { if (variables.filter) { return { ...params, - filter: variables.filter, + filter: `name:ilike:${variables.filter}`, } } diff --git a/src/components/metadataFormControls/LegendSetTransfer/LegendSetTransfer.tsx b/src/components/metadataFormControls/LegendSetTransfer/LegendSetTransfer.tsx new file mode 100644 index 00000000..10d799d4 --- /dev/null +++ b/src/components/metadataFormControls/LegendSetTransfer/LegendSetTransfer.tsx @@ -0,0 +1,150 @@ +import i18n from '@dhis2/d2-i18n' +import { Transfer } from '@dhis2/ui' +import React, { + ReactElement, + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, +} from 'react' +import { SelectOption } from '../../../types' +import { useInitialOptionQuery } from './useInitialOptionQuery' +import { useOptionsQuery } from './useOptionsQuery' + +function computeDisplayOptions({ + selected, + selectedOptions, + options, +}: { + options: SelectOption[] + selected: string[] + selectedOptions: SelectOption[] +}): SelectOption[] { + // This happens only when we haven't fetched the lable for an initially + // selected value. Don't show anything to prevent error that an option is + // missing + if (!selectedOptions.length && selected.length) { + return [] + } + + const missingSelectedOptions = selectedOptions.filter((selectedOption) => { + return !options?.find((option) => option.value === selectedOption.value) + }) + + return [...options, ...missingSelectedOptions] +} + +interface LegendSetSelectProps { + onChange: ({ selected }: { selected: string[] }) => void + selected: string[] + rightHeader?: ReactElement + rightFooter?: ReactElement + leftFooter?: ReactElement + leftHeader?: ReactElement +} + +export const LegendSetTransfer = forwardRef(function LegendSetSelect( + { + onChange, + selected, + rightHeader, + rightFooter, + leftFooter, + leftHeader, + }: LegendSetSelectProps, + ref +) { + // Using a ref because we don't want to react to changes. + // We're using this value only when imperatively calling `refetch`, + // nothing that depends on the render-cycle depends on this value + const [searchTerm, setSearchTerm] = useState('') + const pageRef = useRef(0) + + // We need to persist the selected option so we can display an