From c132fd693f072ba12ad9c17890fbccdb64e1f805 Mon Sep 17 00:00:00 2001 From: Birk Johansson Date: Mon, 15 Jan 2024 18:20:55 +0100 Subject: [PATCH] feat(filters): rework config for better type safety for dynamic filters --- .../sectionList/SectionListLayout.tsx | 0 .../sectionList/filters/filtersKeys.ts | 0 .../filters/useSectionListFilter.ts | 31 +++- src/lib/constants/index.ts | 1 + src/lib/constants/sectionListView/index.ts | 3 + .../sectionListViewFilterKeys.ts | 25 +++ .../sectionListView/sectionListViewsConfig.ts | 91 +++++++++++ .../sectionListView/viewConfigResolver.ts | 142 ++++++++++++++++++ src/lib/constants/sectionListViewsConfig.ts | 21 ++- src/lib/routeUtils/CustomQueryParam.ts | 29 +++- 10 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 src/components/sectionList/SectionListLayout.tsx create mode 100644 src/components/sectionList/filters/filtersKeys.ts create mode 100644 src/lib/constants/sectionListView/index.ts create mode 100644 src/lib/constants/sectionListView/sectionListViewFilterKeys.ts create mode 100644 src/lib/constants/sectionListView/sectionListViewsConfig.ts create mode 100644 src/lib/constants/sectionListView/viewConfigResolver.ts diff --git a/src/components/sectionList/SectionListLayout.tsx b/src/components/sectionList/SectionListLayout.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/sectionList/filters/filtersKeys.ts b/src/components/sectionList/filters/filtersKeys.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/sectionList/filters/useSectionListFilter.ts b/src/components/sectionList/filters/useSectionListFilter.ts index 088d4c1c..b251aa84 100644 --- a/src/components/sectionList/filters/useSectionListFilter.ts +++ b/src/components/sectionList/filters/useSectionListFilter.ts @@ -1,17 +1,19 @@ import { useCallback, useMemo } from 'react' import { useQueryParam, ObjectParam, UrlUpdateType } from 'use-query-params' -import { Schema, useSchemaFromHandle, CustomObjectParam } from '../../../lib' +import { + Schema, + useSchemaFromHandle, + CustomObjectParam, + SectionListFilterObjectParamType, + getViewConfigForSection, + IDENTIFIABLE_KEY, +} from '../../../lib' import { usePaginationQueryParams } from '../SectionListPagination' type ObjectParamType = typeof ObjectParam.default type Filters = Record -// special key for handling search for identifiable objects -// eg. searches for name, code, id and shortname -// this would translate to "token" in the old API, but does not exist in GIST-API -export const IDENTIFIABLE_KEY = 'identifiable' - const getVerifiedFiltersForSchema = ( filters: ObjectParamType, schema: Schema @@ -26,6 +28,23 @@ const getVerifiedFiltersForSchema = ( return Object.fromEntries(relevantFilters) } +const getRelevantFiltersForSchema = ( + filters: SectionListFilterObjectParamType, + schema: Schema +): Filters => { + if (!filters) { + return {} + } + const viewConfig = getViewConfigForSection(schema.singular) + const relevantFilterKeys = + viewConfig.filters.available.concat('identifiable') + /* TODO: verify values for filters */ + const relevantFilters = Object.entries(filters).filter(([key]) => { + return key === IDENTIFIABLE_KEY || schema.properties[key] + }) + return Object.fromEntries(relevantFilters) +} + const useFilterQueryParam = () => { return useQueryParam('filter', CustomObjectParam) } diff --git a/src/lib/constants/index.ts b/src/lib/constants/index.ts index 369de77f..3c39243d 100644 --- a/src/lib/constants/index.ts +++ b/src/lib/constants/index.ts @@ -2,3 +2,4 @@ export * from './sectionListViewsConfig' export * from './sections' export * from './translatedModelConstants' export * from './translatedModelProperties' +export * from './sectionListView' diff --git a/src/lib/constants/sectionListView/index.ts b/src/lib/constants/sectionListView/index.ts new file mode 100644 index 00000000..2b787cf4 --- /dev/null +++ b/src/lib/constants/sectionListView/index.ts @@ -0,0 +1,3 @@ +export * from './viewConfigResolver' +export * from './sectionListViewFilterKeys' +// export * from './sectionListViewsConfig' diff --git a/src/lib/constants/sectionListView/sectionListViewFilterKeys.ts b/src/lib/constants/sectionListView/sectionListViewFilterKeys.ts new file mode 100644 index 00000000..e0c8b779 --- /dev/null +++ b/src/lib/constants/sectionListView/sectionListViewFilterKeys.ts @@ -0,0 +1,25 @@ +// special key for handling search for identifiable objects +// eg. searches for name, code, id and shortname +// this would translate to "token" in the old API, but does not exist in GIST-API +export const IDENTIFIABLE_KEY = 'identifiable' + +/* Allowed "keys" to filter by + Used to specify the allowed filters in the query-Params as well + as mapping to the correct filter component */ +export const validFilterKeys = [ + IDENTIFIABLE_KEY, + 'domainType', + 'valueType', + 'dataSet', + 'categoryCombo', +] as const + +const filterKeysSet = new Set(validFilterKeys) + +export type FilterKeys = typeof validFilterKeys +//export const filterKeys = Object.keys(filterKeys) + +export type FilterKey = FilterKeys[number] + +// Identifiable is not configurable, and is always shown in the list +export type ConfigurableFilterKey = Exclude diff --git a/src/lib/constants/sectionListView/sectionListViewsConfig.ts b/src/lib/constants/sectionListView/sectionListViewsConfig.ts new file mode 100644 index 00000000..caed7c54 --- /dev/null +++ b/src/lib/constants/sectionListView/sectionListViewsConfig.ts @@ -0,0 +1,91 @@ +import i18n from '@dhis2/d2-i18n' +import type { ConfigurableFilterKey } from './sectionListViewFilterKeys' + +export interface ModelPropertyDescriptor { + label: string + path: string +} + +export interface FilterDescriptor { + label: string + filterKey: ConfigurableFilterKey +} + +/* Configs can either define the label and filterKey, or a string +If config is a string, getTranslatedProperty will be used to get the label. */ + +export type FilterConfig = ConfigurableFilterKey | FilterDescriptor + +export type ModelPropertyConfig = string | ModelPropertyDescriptor + +export interface ViewConfigPart { + available?: ReadonlyArray + overrideDefaultAvailable?: boolean + default?: ReadonlyArray +} + +export interface ViewConfig { + columns: ViewConfigPart + filters: ViewConfigPart +} + +// generic here is just used for "satisfies" below, for code-completion of future customizations +// cant use "[key in SectionName]" here, because section.name might be a "string" +export type SectionListViewConfig = { + [key in Key]?: ViewConfig +} + +const DESCRIPTORS = { + publicAccess: { path: 'sharing.public', label: i18n.t('Public access') }, +} satisfies Record + +// This is the default views, and can be overriden per section in modelListViewsConfig below +export const defaultModelViewConfig = { + columns: { + available: [ + 'name', + 'shortName', + 'code', + 'created', + 'createdBy', + 'href', + 'id', + 'lastUpdatedBy', + DESCRIPTORS.publicAccess, + ], + default: ['name', DESCRIPTORS.publicAccess, 'lastUpdated'], + }, + filters: { + available: [], + default: [ + // NOTE: Identifiable is special, and is always included in the default filters + // It should not be handled the same way as "configurable" filters + ], + }, +} satisfies ViewConfig + +/* this is the default views (eg. which columns and filters) to show in the List-page for each section + Note: by default, the available columns are merged with columnsDefault.available above. + If it's needed to override this for a section, set overrideDefaultAvailable to true + and list all available columns in the available array below. + Default-list will NOT be merged with columnsDefault.default - list all explicitly. + elements in the default array implies they are also available, no need to list them in both. */ + +export const modelListViewsConfig = { + dataElement: { + columns: { + available: ['zeroIsSignificant', 'categoryCombo'], + default: [ + 'name', + { label: i18n.t('Domain'), path: 'domainType' }, + { label: i18n.t('Value type'), path: 'valueType' }, + 'lastUpdated', + 'sharing.public', + ], + }, + filters: { + default: ['domainType', 'valueType'], + available: ['categoryCombo'], + }, + }, +} satisfies SectionListViewConfig diff --git a/src/lib/constants/sectionListView/viewConfigResolver.ts b/src/lib/constants/sectionListView/viewConfigResolver.ts new file mode 100644 index 00000000..c7434499 --- /dev/null +++ b/src/lib/constants/sectionListView/viewConfigResolver.ts @@ -0,0 +1,142 @@ +import { uniqueBy } from '../../utils' +import { getTranslatedProperty } from '../translatedModelProperties' +import { + defaultModelViewConfig, + modelListViewsConfig, + ViewConfigPart, + ModelPropertyConfig, + ModelPropertyDescriptor, + FilterDescriptor, + FilterConfig, +} from './sectionListViewsConfig' + +interface ResolvedViewConfigPart { + available: ReadonlyArray + default: ReadonlyArray +} +interface ResolvedViewConfig { + columns: ResolvedViewConfigPart + filters: ResolvedViewConfigPart +} + +interface ResolvedSectionListView { + [key: string]: ResolvedViewConfig +} + +const toModelPropertyDescriptor = ( + propertyConfig: ModelPropertyConfig +): ModelPropertyDescriptor => { + if (typeof propertyConfig === 'string') { + return { + label: getTranslatedProperty(propertyConfig), + path: propertyConfig, + } + } + return propertyConfig +} + +const toFilterDescriptor = (propertyConfig: FilterConfig): FilterDescriptor => { + if (typeof propertyConfig === 'string') { + return { + label: getTranslatedProperty(propertyConfig), + filterKey: propertyConfig, + } + } + return propertyConfig +} + +const resolveFilterConfig = ( + part: ViewConfigPart +): ResolvedViewConfigPart => { + const { default: defaultFilters, available: defaultAvailableFilers } = + defaultModelViewConfig.filters + + const mergedAvailableDescriptors = uniqueBy( + [ + part.available || [], + part.overrideDefaultAvailable ? [] : defaultAvailableFilers || [], + part.default || [], + ] + .flat() + .map((propConfig) => toFilterDescriptor(propConfig)), + (prop) => prop.filterKey + ) + const defaultPropConfig = part.default || defaultFilters || [] + const defaultDescriptors = defaultPropConfig.map((propConfig) => + toFilterDescriptor(propConfig) + ) + return { + available: mergedAvailableDescriptors, + default: defaultDescriptors, + } +} + +const resolveColumnConfig = ( + part: ViewConfigPart +): ResolvedViewConfigPart => { + const { default: defaultFilters, available: defaultAvailableFilers } = + defaultModelViewConfig.columns + + const mergedAvailableDescriptors = uniqueBy( + [ + part.available || [], + part.overrideDefaultAvailable ? [] : defaultAvailableFilers || [], + part.default || [], + ] + .flat() + .map((propConfig) => toModelPropertyDescriptor(propConfig)), + (prop) => prop.path + ) + const defaultPropConfig = part.default || defaultFilters || [] + const defaultDescriptors = defaultPropConfig.map((propConfig) => + toModelPropertyDescriptor(propConfig) + ) + return { + available: mergedAvailableDescriptors, + default: defaultDescriptors, + } +} + +// merge the default modelViewConfig with the modelViewsConfig for each section +const resolveListViewsConfig = () => { + const merged: ResolvedSectionListView = {} + + Object.entries(modelListViewsConfig).forEach((viewConfig) => { + const [sectionName, sectionViewConfig] = viewConfig + merged[sectionName] = { + columns: resolveColumnConfig(sectionViewConfig.columns), + filters: resolveFilterConfig(sectionViewConfig.filters), + } + }) + return merged +} + +const mergedModelViewsConfig = resolveListViewsConfig() +const resolvedDefaultConfig = { + columns: resolveColumnConfig(defaultModelViewConfig.columns), + filters: resolveFilterConfig(defaultModelViewConfig.filters), +} + +export const getViewConfigForSection = ( + sectionName: string +): ResolvedViewConfig => { + const resolvedConfig = mergedModelViewsConfig[sectionName] + if (resolvedConfig) { + return resolvedConfig + } + return resolvedDefaultConfig +} + +export const getColumnsForSection = ( + sectionName: string +): ResolvedViewConfig['columns'] => { + const view = getViewConfigForSection(sectionName) + return view.columns +} + +export const getFiltersForSection = ( + sectionName: string +): ResolvedViewConfig['filters'] => { + const view = getViewConfigForSection(sectionName) + return view.filters +} diff --git a/src/lib/constants/sectionListViewsConfig.ts b/src/lib/constants/sectionListViewsConfig.ts index 91a8f204..899f1bed 100644 --- a/src/lib/constants/sectionListViewsConfig.ts +++ b/src/lib/constants/sectionListViewsConfig.ts @@ -34,6 +34,10 @@ type SectionListViewConfig = { [key in Key]?: ViewConfig } +const DESCRIPTORS = { + publicAccess: { path: 'sharing.public', label: i18n.t('Public access') }, +} satisfies Record + // This is the default views, and can be overriden per section in modelListViewsConfig below const defaultModelViewConfig = { columns: { @@ -46,10 +50,7 @@ const defaultModelViewConfig = { 'href', 'id', 'lastUpdatedBy', - { - label: i18n.t('Public access'), - path: 'sharing.public', - }, + DESCRIPTORS.publicAccess, ], default: ['name', 'sharing.public', 'lastUpdated'], }, @@ -69,13 +70,21 @@ const defaultModelViewConfig = { const modelListViewsConfig = { dataElement: { columns: { - available: ['zeroIsSignificant', 'categoryCombo'], + available: [ + 'zeroIsSignificant', + 'categoryCombo', + // { + // label: i18n.t('Hello available public'), + // path: 'sharing.public', + // }, + ], default: [ 'name', { label: i18n.t('Domain'), path: 'domainType' }, { label: i18n.t('Value type'), path: 'valueType' }, 'lastUpdated', - 'sharing.public', + // 'sharing.public', + { label: i18n.t('Hello public'), path: 'sharing.public' }, ], }, filters: { diff --git a/src/lib/routeUtils/CustomQueryParam.ts b/src/lib/routeUtils/CustomQueryParam.ts index df64773a..8cc9bf91 100644 --- a/src/lib/routeUtils/CustomQueryParam.ts +++ b/src/lib/routeUtils/CustomQueryParam.ts @@ -1,5 +1,14 @@ -import { encodeObject, decodeObject, ObjectParam } from 'use-query-params' - +import pick from 'lodash/pick' +import { + encodeObject, + decodeObject, + ObjectParam, + QueryParamConfig, +} from 'use-query-params' +import { + FilterKey, + validFilterKeys, +} from '../constants/sectionListView/sectionListViewFilterKeys' const entrySeparator = '~' // default is "_" which breaks constants (delimited by _) export const CustomObjectParam: typeof ObjectParam = { @@ -7,3 +16,19 @@ export const CustomObjectParam: typeof ObjectParam = { decode: (str) => decodeObject(str, undefined, entrySeparator), } + +type FilterObject = { [key in FilterKey]?: string } | undefined | null +export type SectionListFilterObjectParamType = QueryParamConfig + +export const SectionListFilterObjectParam: SectionListFilterObjectParamType = { + encode: (obj) => { + const filteredObj = pick(obj, validFilterKeys) + + return encodeObject(filteredObj, undefined, entrySeparator) + }, + + decode: (str) => { + const decoded = decodeObject(str, undefined, entrySeparator) + return pick(decoded, validFilterKeys) + }, +}