diff --git a/global.d.ts b/global.d.ts index 24670d12..4c448a63 100644 --- a/global.d.ts +++ b/global.d.ts @@ -3,6 +3,7 @@ declare module '@dhis2/d2-i18n' { const language: string export function t(key: string, options?: any): string + export function exists(key: string): boolean } declare module '@dhis2/ui' diff --git a/i18n/en.pot b/i18n/en.pot index 298202fa..74860280 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-05T12:02:44.326Z\n" -"PO-Revision-Date: 2023-09-05T12:02:44.326Z\n" +"POT-Creation-Date: 2023-09-12T11:17:52.981Z\n" +"PO-Revision-Date: 2023-09-12T11:17:52.981Z\n" msgid "schemas" msgstr "schemas" @@ -48,6 +48,12 @@ msgstr "Search for menu items" msgid "Retry" msgstr "Retry" +msgid "All" +msgstr "All" + +msgid "Category combo" +msgstr "Category combo" + msgid "Failed to load {{label}}" msgstr "Failed to load {{label}}" @@ -87,12 +93,33 @@ msgstr "Type to filter options" msgid "No matches" msgstr "No matches" +msgid "Data set" +msgstr "Data set" + msgid "Clear all filters" msgstr "Clear all filters" msgid "Search by name, code or ID" msgstr "Search by name, code or ID" +msgid "Available table columns" +msgstr "Available table columns" + +msgid "Selected table columns" +msgstr "Selected table columns" + +msgid "Reset to default columns" +msgstr "Reset to default columns" + +msgid "Manage {{section}} table columns" +msgstr "Manage {{section}} table columns" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Update table columns" +msgstr "Update table columns" + msgid "Public can edit" msgstr "Public can edit" @@ -156,9 +183,6 @@ msgstr "Data element group set" msgid "Data element group sets" msgstr "Data element group sets" -msgid "Data set" -msgstr "Data set" - msgid "Data sets" msgstr "Data sets" @@ -501,20 +525,50 @@ msgstr "Image" msgid "GeoJSON" msgstr "GeoJSON" -msgid "Name" -msgstr "Name" +msgid "Code" +msgstr "Code" + +msgid "Created by" +msgstr "Created by" + +msgid "Favorite" +msgstr "Favorite" + +msgid "Href" +msgstr "Href" + +msgid "Id" +msgstr "Id" + +msgid "Last updated by" +msgstr "Last updated by" -msgid "Domain" -msgstr "Domain" +msgid "Created" +msgstr "Created" -msgid "Value" -msgstr "Value" +msgid "Domain type" +msgstr "Domain type" msgid "Last updated" msgstr "Last updated" -msgid "Public access" -msgstr "Public access" +msgid "Name" +msgstr "Name" + +msgid "Sharing" +msgstr "Sharing" + +msgid "Short name" +msgstr "Short name" + +msgid "Value type" +msgstr "Value type" + +msgid "Owner" +msgstr "Owner" + +msgid "Zero is significant" +msgstr "Zero is significant" msgid "Metadata management" msgstr "Metadata management" diff --git a/package.json b/package.json index c18b4ae2..8b680206 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-router-dom": "^6.11.2", "use-debounce": "^9.0.4", "use-query-params": "^2.2.1", + "zod": "^3.22.2", "zustand": "^4.4.0" } } diff --git a/src/app/routes/types.ts b/src/app/routes/types.ts index 8a69fab0..39a3c394 100644 --- a/src/app/routes/types.ts +++ b/src/app/routes/types.ts @@ -1,6 +1,5 @@ import type { useMatches } from 'react-router-dom' -import type { SchemaSection } from '../../constants/sections' - +import type { ModelSection } from '../../types' // utility type to type a match with a handle-property returned from useMatches // since handle is unknown, we need to cast it to the correct type type MatchWithHandle = ReturnType[number] & { @@ -10,7 +9,7 @@ type MatchWithHandle = ReturnType[number] & { // common type for possible handle-properties used in Route export type RouteHandle = { hideSidebar?: boolean - section?: SchemaSection + section?: ModelSection crumb?: () => React.ReactNode } diff --git a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx index 3b1aaeb9..d0d749c5 100644 --- a/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx +++ b/src/components/SearchableSingleSelect/SearchableSingleSelect.tsx @@ -58,11 +58,13 @@ interface SearchableSingleSelectPropTypes { selected?: string error?: string showAllOption?: boolean + label: string } export const SearchableSingleSelect = ({ showAllOption, error, + label, loading, onChange, onFilterChange, @@ -96,7 +98,6 @@ export const SearchableSingleSelect = ({ const observer = new IntersectionObserver( (entries) => { const [{ isIntersecting }] = entries - if (isIntersecting) { onEndReached() } @@ -124,7 +125,7 @@ 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={label} >
diff --git a/src/components/metadataSelects/CategoryComboSelect/CategoryComboSelect.tsx b/src/components/metadataSelects/CategoryComboSelect/CategoryComboSelect.tsx index be222eec..0e79a2db 100644 --- a/src/components/metadataSelects/CategoryComboSelect/CategoryComboSelect.tsx +++ b/src/components/metadataSelects/CategoryComboSelect/CategoryComboSelect.tsx @@ -1,3 +1,4 @@ +import i18n from '@dhis2/d2-i18n' import React, { useCallback, useRef, useState } from 'react' import { SelectOption } from '../../../types' import { SearchableSingleSelect } from '../../SearchableSingleSelect' @@ -92,6 +93,7 @@ export function CategoryComboSelect({ return ( { if (selected === selectedOption?.value) { diff --git a/src/components/sectionList/SectionList.tsx b/src/components/sectionList/SectionList.tsx index 92e923bf..e772d4dd 100644 --- a/src/components/sectionList/SectionList.tsx +++ b/src/components/sectionList/SectionList.tsx @@ -9,21 +9,20 @@ import { } from '@dhis2/ui' import React, { PropsWithChildren } from 'react' import { CheckBoxOnChangeObject } from '../../types' -import { IdentifiableObject } from '../../types/generated' import { SelectedColumns } from './types' -type SectionListProps = { - headerColumns: SelectedColumns +type SectionListProps = { + headerColumns: SelectedColumns onSelectAll: (checked: boolean) => void allSelected?: boolean } -export const SectionList = ({ +export const SectionList = ({ allSelected, headerColumns, children, onSelectAll, -}: PropsWithChildren>) => { +}: PropsWithChildren) => { return ( @@ -37,19 +36,27 @@ export const SectionList = ({ } /> - {headerColumns.map((headerColumn) => ( - - {headerColumn.label} - - ))} - - {i18n.t('Actions')} - + {headerColumns.length > 0 && ( + + )} {children} ) } + +const HeaderColumns = ({ + headerColumns, +}: { + headerColumns: SelectedColumns +}) => ( + <> + {headerColumns.map((headerColumn) => ( + + {headerColumn.label} + + ))} + {i18n.t('Actions')} + +) diff --git a/src/components/sectionList/SectionListRow.tsx b/src/components/sectionList/SectionListRow.tsx index 90429c73..ac1ecae1 100644 --- a/src/components/sectionList/SectionListRow.tsx +++ b/src/components/sectionList/SectionListRow.tsx @@ -9,13 +9,10 @@ import { SelectedColumns, SelectedColumn } from './types' export type SectionListRowProps = { modelData: GistModel - selectedColumns: SelectedColumns + selectedColumns: SelectedColumns onSelect: (modelId: string, checked: boolean) => void selected: boolean - renderValue: ( - column: SelectedColumn['modelPropertyName'], - value: GistModel[typeof column] - ) => React.ReactNode + renderColumnValue: (column: SelectedColumn) => React.ReactNode } export function SectionListRow({ @@ -23,7 +20,7 @@ export function SectionListRow({ modelData, onSelect, selected, - renderValue, + renderColumnValue, }: SectionListRowProps) { return ( ({ }} /> - {selectedColumns.map(({ modelPropertyName }) => ( - - {modelData[modelPropertyName] && - renderValue( - modelPropertyName, - modelData[modelPropertyName] - )} + {selectedColumns.map((selectedColumn) => ( + + {renderColumnValue(selectedColumn)} ))} diff --git a/src/components/sectionList/SectionListWrapper.tsx b/src/components/sectionList/SectionListWrapper.tsx index 5143507d..c0dfe3ae 100644 --- a/src/components/sectionList/SectionListWrapper.tsx +++ b/src/components/sectionList/SectionListWrapper.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useState } from 'react' import { useSchemaFromHandle } from '../../lib' import { IdentifiableObject, GistCollectionResponse } from '../../types/models' import { FilterWrapper } from './filters/FilterWrapper' +import { useModelListView } from './listView' import { ModelValue } from './modelValue/ModelValue' import { SectionList } from './SectionList' import { SectionListLoader } from './SectionListLoader' @@ -11,25 +12,20 @@ import { SectionListPagination } from './SectionListPagination' import { SectionListRow } from './SectionListRow' import { SectionListTitle } from './SectionListTitle' import { SelectionListHeader } from './SelectionListHeaderNormal' -import { SelectedColumns } from './types' type SectionListWrapperProps = { - availableColumns?: SelectedColumns - defaultColumns: SelectedColumns filterElement?: React.ReactElement data: GistCollectionResponse | undefined error: FetchError | undefined } + export const SectionListWrapper = ({ - availableColumns, - defaultColumns, filterElement, data, error, }: SectionListWrapperProps) => { + const { columns: headerColumns } = useModelListView() const schema = useSchemaFromHandle() - const [selectedColumns, setSelectedColumns] = - useState>(defaultColumns) const [selectedModels, setSelectedModels] = useState>(new Set()) const handleSelect = (id: string, checked: boolean) => { @@ -82,7 +78,7 @@ export const SectionListWrapper = ({ {filterElement} @@ -91,15 +87,20 @@ export const SectionListWrapper = ({ { + renderColumnValue={({ path }) => { return ( c.path === path + )?.component + } + model={model} /> ) }} diff --git a/src/components/sectionList/SelectionListHeaderNormal.tsx b/src/components/sectionList/SelectionListHeaderNormal.tsx index 4a0c26dc..8c9ecbae 100644 --- a/src/components/sectionList/SelectionListHeaderNormal.tsx +++ b/src/components/sectionList/SelectionListHeaderNormal.tsx @@ -4,9 +4,13 @@ import { IconAdd24 } from '@dhis2/ui-icons' import React from 'react' import { Link } from 'react-router-dom' import { routePaths } from '../../app/routes/routePaths' +import { ManageListViewDialog } from './listView/ManageListViewDialog' import css from './SectionList.module.css' export const SelectionListHeader = () => { + const [manageColumnsOpen, setManageColumnsOpen] = React.useState(false) + + const handleClose = () => setManageColumnsOpen(false) return (
@@ -15,7 +19,12 @@ export const SelectionListHeader = () => { - + + {manageColumnsOpen && ( + + )}
) } diff --git a/src/components/sectionList/filters/GenericSelectionFilter.tsx b/src/components/sectionList/filters/GenericSelectionFilter.tsx new file mode 100644 index 00000000..591aec7e --- /dev/null +++ b/src/components/sectionList/filters/GenericSelectionFilter.tsx @@ -0,0 +1,115 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import React, { useState } from 'react' +import { useInfiniteQuery, QueryFunctionContext } from 'react-query' +import { Query } from '../../../types' +import { + BaseIdentifiableObject, + ModelCollectionResponse, +} from '../../../types/generated' +import { SearchableSingleSelect } from '../../SearchableSingleSelect' +import { useSectionListFilter } from './useSectionListFilter' + +type SimpleQuery = { + resource: string + fields?: string[] + order?: string[] +} + +type GenericSelectionFilterProps = { + label: string + filterKey: string + query: SimpleQuery +} +type WrapInData = { data: T } + +const infiniteQueryFn = + (dataEngine: ReturnType) => + ({ + queryKey: [query], + pageParam = 1, + signal, + }: QueryFunctionContext<[Query], number>) => { + const pagedQuery = { + data: { + ...query.data, + params: { + ...query.data.params, + page: pageParam, + }, + }, + } + return dataEngine.query(pagedQuery, { + signal, + }) as Promise< + WrapInData> + > + } + +export const GenericSelectionFilter = ({ + filterKey, + label, + query, +}: GenericSelectionFilterProps) => { + const dataEngine = useDataEngine() + const [filter, setFilter] = useSectionListFilter(filterKey) + const [searchValue, setSearchValue] = useState('') + + const handleChange = ({ selected }: { selected: string }) => { + setFilter(selected) + } + + const optionsQuery = { + data: { + resource: query.resource, + params: { + pageSize: 20, + fields: + query.fields?.length && query.fields?.length > 0 + ? query.fields + : ['id', 'displayName'], + filter: searchValue + ? `displayName:ilike:${searchValue}` + : undefined, + }, + }, + } + + const res = useInfiniteQuery({ + queryKey: [optionsQuery], + queryFn: infiniteQueryFn(dataEngine), + getNextPageParam: (lastPage) => { + const pager = lastPage?.data?.pager + return pager.nextPage ? pager.page + 1 : undefined + }, + getPreviousPageParam: (lastPage) => { + const pager = lastPage?.data?.pager + return pager.nextPage ? pager.page - 1 : undefined + }, + }) + + const displayOptions = + res.data?.pages.flatMap((p) => + p.data[query.resource].map(({ id, displayName }) => ({ + value: id, + label: displayName, + })) + ) ?? [] + + return ( + setSearchValue(value)} + loading={false} + label={label} + error={res.error?.toString()} + onRetryClick={() => { + res.refetch() + }} + /> + ) +} diff --git a/src/components/sectionList/filters/useSectionListFilter.ts b/src/components/sectionList/filters/useSectionListFilter.ts index 535e9e98..8da8a8cd 100644 --- a/src/components/sectionList/filters/useSectionListFilter.ts +++ b/src/components/sectionList/filters/useSectionListFilter.ts @@ -126,6 +126,9 @@ const parseToGistQueryFilter = (filters: Filters): string[] => { restFilterGroup = 1 } Object.entries(restFilters).forEach(([key, value]) => { + if (key === 'uid') { + key = 'id' + } const group = restFilterGroup ? `${restFilterGroup++}:` : '' queryFilters.push(`${group}${key}:eq:${value}`) }) diff --git a/src/components/sectionList/listView/ManageListView.module.css b/src/components/sectionList/listView/ManageListView.module.css new file mode 100644 index 00000000..71f62f86 --- /dev/null +++ b/src/components/sectionList/listView/ManageListView.module.css @@ -0,0 +1,9 @@ +.resetDefaultButton { + margin-top: var(--spacers-dp12) !important; +} + +.transferHeader { + margin: var(--spacers-dp8) 0px; + color: var(--colors-grey700); + font-weight: 400; +} diff --git a/src/components/sectionList/listView/ManageListView.tsx b/src/components/sectionList/listView/ManageListView.tsx new file mode 100644 index 00000000..b89ac11e --- /dev/null +++ b/src/components/sectionList/listView/ManageListView.tsx @@ -0,0 +1,108 @@ +import i18n from '@dhis2/d2-i18n' +import { Button, Transfer } from '@dhis2/ui' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { getColumnsForSection } from '../../../constants' +import { useModelSectionHandleOrThrow } from '../../../lib' +import css from './ManageListView.module.css' +import { useModelListView, useMutateModelListViews } from './useModelListView' + +interface RenderProps { + handleSave: () => void + isSaving: boolean +} +type ManageColumnsDialogProps = { + onSaved: () => void + children: (props: RenderProps) => React.ReactNode +} + +const toPath = (propertyDescriptor: { path: string }) => propertyDescriptor.path + +export const ManageListView = ({ + onSaved, + children, +}: ManageColumnsDialogProps) => { + const section = useModelSectionHandleOrThrow() + // ignore updates to saved-columns while selecting + const isTouched = useRef(false) + + const { columns: savedColumns, query } = useModelListView() + const [pendingSelectedColumns, setPendingSelectedColumns] = useState< + string[] + >(() => savedColumns.map(toPath)) + const { saveColumns, mutation } = useMutateModelListViews() + + const columnsConfig = getColumnsForSection(section.name) + + useEffect(() => { + // if savedColumns were to update while selecting (it shouldn't ) + // make sure to not overwrite the selected columns + if (isTouched.current) { + return + } + setPendingSelectedColumns(savedColumns.map(toPath)) + }, [savedColumns]) + + const handleSave = () => { + saveColumns(pendingSelectedColumns, { + onSuccess: () => onSaved(), + }) + } + + const handleSetDefault = () => { + setPendingSelectedColumns(columnsConfig.default.map(toPath)) + } + + const handleChange = ({ selected }: { selected: string[] }) => { + isTouched.current = true + setPendingSelectedColumns(selected) + } + + const transferOptions = useMemo( + () => + columnsConfig.available + .map((column) => ({ + label: column.label, + value: column.path, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + [columnsConfig.available] + ) + + return ( + <> + + {i18n.t('Available table columns')} + + } + rightHeader={ + + {i18n.t('Selected table columns')} + + } + onChange={handleChange} + loading={query.isLoading} + loadingPicked={query.isLoading} + options={transferOptions} + selected={pendingSelectedColumns} + /> + + {children({ handleSave, isSaving: mutation.isLoading })} + + ) +} + +const TransferHeader = ({ children }: React.PropsWithChildren) => ( +
{children}
+) diff --git a/src/components/sectionList/listView/ManageListViewDialog.tsx b/src/components/sectionList/listView/ManageListViewDialog.tsx new file mode 100644 index 00000000..550e8020 --- /dev/null +++ b/src/components/sectionList/listView/ManageListViewDialog.tsx @@ -0,0 +1,51 @@ +import i18n from '@dhis2/d2-i18n' +import { + Modal, + ModalActions, + ModalContent, + ModalTitle, + Button, + ButtonStrip, +} from '@dhis2/ui' +import React from 'react' +import { useModelSectionHandleOrThrow } from '../../../lib' +import { ManageListView } from './ManageListView' + +type ManageListViewDialogProps = { + onClose: () => void +} +export const ManageListViewDialog = ({ + onClose, +}: ManageListViewDialogProps) => { + const section = useModelSectionHandleOrThrow() + + return ( + + + {i18n.t('Manage {{section}} table columns', { + section: section.title, + })} + + + + {({ handleSave, isSaving }) => ( + + + + + + + )} + + + + ) +} diff --git a/src/components/sectionList/listView/index.ts b/src/components/sectionList/listView/index.ts new file mode 100644 index 00000000..4dca5069 --- /dev/null +++ b/src/components/sectionList/listView/index.ts @@ -0,0 +1,2 @@ +export * from './ManageListViewDialog' +export * from './useModelListView' diff --git a/src/components/sectionList/listView/types.ts b/src/components/sectionList/listView/types.ts new file mode 100644 index 00000000..86e13114 --- /dev/null +++ b/src/components/sectionList/listView/types.ts @@ -0,0 +1,13 @@ +import { SectionName } from '../../../constants' +import type { ModelPropertyDescriptor } from '../../../constants' + +export interface ModelListView { + name: string + sectionModel: string + columns: Array + filters: Array +} + +export type ModelListViews = { + [key in SectionName]?: ModelListView[] +} diff --git a/src/components/sectionList/listView/useModelListView.tsx b/src/components/sectionList/listView/useModelListView.tsx new file mode 100644 index 00000000..ae8a98a4 --- /dev/null +++ b/src/components/sectionList/listView/useModelListView.tsx @@ -0,0 +1,221 @@ +import { useMemo, useCallback } from 'react' +import { useQueryClient } from 'react-query' +import { z } from 'zod' +import { getViewConfigForSection, sectionNames } from '../../../constants' +import { useModelSectionHandleOrThrow } from '../../../lib' +import { useDataStoreValues } from '../../../lib/dataStore' +import { + queryCreators, + useMutateDataStoreValues, +} from '../../../lib/dataStore/useDataStore' +import { ModelListView } from './types' + +const maintenanceNamespace = 'maintenance' +const configurableColumnsKey = 'modelListViews' + +const valuesQueryKey = [ + queryCreators.getValues({ + namespace: maintenanceNamespace, + key: configurableColumnsKey, + }), +] + +const modelListViewSchema = z.object({ + name: z.string(), + sectionModel: z.string(), + columns: z.array(z.string()), + filters: z.array(z.string()), +}) + +type DataModelListView = z.infer + +const modelListViewsSchema = z + // TODO: support only one view for now - but update this to support multiple views + .record( + z + .string() + .refine((val) => sectionNames.has(val), 'Not a valid section'), + z.array(modelListViewSchema).length(1) + ) + .refine((val) => Object.keys(val).length > 0) +type DataModelListViews = z.infer + +const getDefaultViewForSection = (sectionName: string): ModelListView => { + const defaultViewConfig = getViewConfigForSection(sectionName) + return { + name: 'default', + sectionModel: sectionName, + columns: defaultViewConfig.columns.default, + filters: defaultViewConfig.filters.default, + } +} + +// parses and validates stored data in UserDataStore to internal format +// labels are not stored since these are translated +const parseViewToModelListView = ( + data: DataModelListView, + name: string +): ModelListView => { + const listView = modelListViewSchema.safeParse(data) + if (!listView.success) { + return getDefaultViewForSection(name) + } + const viewConfig = getViewConfigForSection(name) + + const parsedView = listView.data + + const availableColumnsMap = new Map( + viewConfig.columns.available.map((c) => [c.path, c] as const) + ) + // map to config to make sure we don't use invalid columns + // Preserve order by mapping from parsedView to config-object + const columns = parsedView.columns + .filter((col) => availableColumnsMap.has(col)) + .map((col) => { + const columnConfig = availableColumnsMap.get(col) + return columnConfig as NonNullable + }) + + const filters = viewConfig.filters.available.filter((col) => + parsedView.filters.includes(col.path) + ) + + return { + ...parsedView, + columns, + filters, + } +} + +const formatViewToDataStore = ( + view: ModelListView +): z.infer => { + const savedView = { + ...view, + columns: view.columns.map((c) => c.path), + filters: view.filters.map((f) => f.path), + } + + return savedView +} + +// check that columns are valid - because data in dataStore should not +// be trusted - since there's no validation server-side. +// we use same dataStore-keys as old app to be backwards-compatible +// this stores columns as filters - eg. categoryCombo[displayName] +// remove this part, sicne we're not interested in them +// also map displayName to name, since in GIST-API 'names' are translated +const createValidViewSelect = (sectionName: string) => { + return (data: DataModelListViews): ModelListView => { + const modelListViews = modelListViewsSchema.safeParse(data) + + if (!modelListViews.success) { + console.warn('Failed to parse modelListViews', modelListViews.error) + return getDefaultViewForSection(sectionName) + } + + const viewForSection = modelListViews.data[sectionName][0] + if (!viewForSection) { + return getDefaultViewForSection(sectionName) + } + return parseViewToModelListView(viewForSection, sectionName) + } +} + +export const useModelListView = () => { + const section = useModelSectionHandleOrThrow() + const select = useMemo(() => createValidViewSelect(section.name), [section]) + + const query = useDataStoreValues({ + namespace: maintenanceNamespace, + key: configurableColumnsKey, + // selects the specific section from the result + select, + }) + + if (query.error) { + console.error(query.error) + } + + const selectedView = query.data || getDefaultViewForSection(section.name) + + const columns = selectedView.columns + const filters = selectedView.filters + + return { view: selectedView, columns, filters, query } +} + +type WrapInResult = { + result: TResult +} +export const useMutateModelListViews = () => { + const section = useModelSectionHandleOrThrow() + const queryClient = useQueryClient() + + const mutation = useMutateDataStoreValues({ + namespace: maintenanceNamespace, + key: configurableColumnsKey, + }) + const mutate = mutation.mutate + + const getListViews = useCallback(() => { + // note, because selectors are per-observer, these are not "mapped" to valid a specific section + // it's exact data as we got from the request + const prevData: WrapInResult | undefined = + queryClient.getQueryData(valuesQueryKey) + if (!prevData) { + return {} + } + + return prevData.result + }, [queryClient]) + + const saveView = useCallback( + async ( + newView: Partial & { name: string }, + mutateOptions?: Parameters[1] + ) => { + const prevData = getListViews() + let viewsForSection = prevData[section.name] + if (!viewsForSection) { + viewsForSection = [ + formatViewToDataStore( + getDefaultViewForSection(section.name) + ), + ] + } + const newViewsForSection = viewsForSection.map((view) => { + if (view.name === newView.name) { + return { + ...view, + ...newView, + } + } + return view + }) + + const newViewsData: DataModelListViews = { + ...prevData, + [section.name]: newViewsForSection, + } + return mutate(newViewsData, mutateOptions) + }, + [mutate, section, getListViews] + ) + + const saveColumns = useCallback( + async ( + columns: string[], + mutateOptions?: Parameters[1] + ) => { + const newView = { + name: 'default', + columns, + } + return saveView(newView, mutateOptions) + }, + [saveView] + ) + + return { mutation, saveColumns } +} diff --git a/src/components/sectionList/modelValue/ModelValue.tsx b/src/components/sectionList/modelValue/ModelValue.tsx index c7fbfbf8..01f1f33e 100644 --- a/src/components/sectionList/modelValue/ModelValue.tsx +++ b/src/components/sectionList/modelValue/ModelValue.tsx @@ -1,40 +1,71 @@ import React from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { Schema, SchemaFieldProperty } from '../../../lib' +import { + Schema, + SchemaFieldProperty, + getIn, + stringToPathArray, +} from '../../../lib' import { ModelValueRenderer } from './ModelValueRenderer' -export type ValueDetails = { - schemaProperty: SchemaFieldProperty - value: unknown -} - type ModelValueProps = { schema: Schema - modelPropertyName: string - value: unknown + path: string + model: unknown + // override renderer + component?: React.ComponentType<{ + value: unknown + schemaProperty: SchemaFieldProperty + }> } const ModelValueError = () => { return Error } +const getSchemaProperty = ( + schema: Schema, + path: string +): SchemaFieldProperty | undefined => { + const pathParts = stringToPathArray(path).map((part) => { + if (part === 'id') { + return 'uid' // fieldName for 'id' is "uid" in schema.properties + } + return part + }) + const rootPath = pathParts[0] + + const schemaProperty = schema.properties[rootPath] + return schemaProperty +} + export const ModelValue = ({ + component, schema, - modelPropertyName, - value, + path, + model, }: ModelValueProps) => { - const schemaProperty = schema.properties[modelPropertyName] + const schemaProperty = getSchemaProperty(schema, path) + + const value = getIn(model, path) - if (!schemaProperty) { + if (!schemaProperty || value == undefined) { console.warn( - `Property ${modelPropertyName} not found in schema, value not rendered: ${value}` + `Property ${path} not found in schema, value not rendered: ${value}` ) return null } return ( - + {component ? ( + React.createElement(component, { value, schemaProperty }) + ) : ( + + )} ) } diff --git a/src/components/sectionList/modelValue/ModelValueRenderer.tsx b/src/components/sectionList/modelValue/ModelValueRenderer.tsx index 49d902a3..a691cc4a 100644 --- a/src/components/sectionList/modelValue/ModelValueRenderer.tsx +++ b/src/components/sectionList/modelValue/ModelValueRenderer.tsx @@ -1,16 +1,16 @@ import React from 'react' +import { SchemaFieldProperty } from '../../../lib' import { BooleanValue } from './BooleanValue' import { ConstantValue } from './ConstantValue' import { DateValue } from './DateValue' -import type { ValueDetails } from './ModelValue' -import { PublicAccessValue, isSharing } from './PublicAccess' import { TextValue } from './TextValue' -export const ModelValueRenderer = ({ value, schemaProperty }: ValueDetails) => { - if (schemaProperty.fieldName === 'sharing' && isSharing(value)) { - return - } +export type ValueDetails = { + schemaProperty: SchemaFieldProperty + value: unknown +} +export const ModelValueRenderer = ({ value, schemaProperty }: ValueDetails) => { if (schemaProperty.propertyType === 'CONSTANT') { return } diff --git a/src/components/sectionList/modelValue/PublicAccess.tsx b/src/components/sectionList/modelValue/PublicAccess.tsx index 714d574f..923a52f8 100644 --- a/src/components/sectionList/modelValue/PublicAccess.tsx +++ b/src/components/sectionList/modelValue/PublicAccess.tsx @@ -7,8 +7,8 @@ export const isSharing = (value: unknown): value is Sharing => { return typeof (value as Sharing).public === 'string' } -const getPublicAccessString = (value: Sharing): string => { - const publicAccess = parsePublicAccessString(value.public) +const getPublicAccessString = (value: string): string => { + const publicAccess = parsePublicAccessString(value) if (!publicAccess) { throw new Error('Invalid public access string') @@ -25,6 +25,6 @@ const getPublicAccessString = (value: Sharing): string => { return i18n.t('Public cannot access') } -export const PublicAccessValue = ({ value }: { value: Sharing }) => { +export const PublicAccessValue = ({ value }: { value: string }) => { return {getPublicAccessString(value)} } diff --git a/src/components/sectionList/types.ts b/src/components/sectionList/types.ts index 7893b291..8f4e5100 100644 --- a/src/components/sectionList/types.ts +++ b/src/components/sectionList/types.ts @@ -1,13 +1,11 @@ import { IdentifiableObject } from '../../types/generated' -export type SelectedColumn = { +export type SelectedColumn = { label: string - modelPropertyName: keyof Model & string + path: string } -export type SelectedColumns< - Model extends IdentifiableObject = IdentifiableObject -> = SelectedColumn[] +export type SelectedColumns = SelectedColumn[] export type CheckBoxOnChangeObject = { checked: boolean diff --git a/src/constants/index.ts b/src/constants/index.ts index b77ec6a3..a202fbe6 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,3 @@ export * from './sections' +export * from './translatedModelProperties' +export * from './sectionListViews' diff --git a/src/constants/sectionListViews.ts b/src/constants/sectionListViews.ts new file mode 100644 index 00000000..542a263e --- /dev/null +++ b/src/constants/sectionListViews.ts @@ -0,0 +1,169 @@ +import i18n from '@dhis2/d2-i18n' +import { PublicAccessValue } from '../components/sectionList/modelValue/PublicAccess' +import { uniqueBy } from '../lib/utils/uniqueBy' +import { SectionName } from './sections' +import { getTranslatedProperty } from './translatedModelProperties' + +export interface ModelPropertyDescriptor { + label: string + path: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component?: React.FC<{ value: any }> +} + +type ModelPropertyConfig = string | ModelPropertyDescriptor +interface ViewConfigPart { + available?: ModelPropertyConfig[] + overrideDefaultAvailable?: boolean + default?: ModelPropertyConfig[] +} + +interface ViewConfig { + columns: ViewConfigPart + filters: ViewConfigPart +} + +interface ResolvedViewConfigPart { + available: ModelPropertyDescriptor[] + default: ModelPropertyDescriptor[] +} +interface ResolvedViewConfig { + columns: ResolvedViewConfigPart + filters: ResolvedViewConfigPart +} + +// generic here is just used for "satisfies" below, for code-completion of future customizations +type SectionListViewConfig = { + [key in Key]?: ViewConfig +} + +// This is the default views, and can be overriden per section in modelListViewsConfig below +const defaultModelViewConfig = { + columns: { + available: [ + 'name', + 'shortName', + 'code', + 'created', + 'createdBy', + 'href', + 'id', + 'lastUpdatedBy', + { + label: i18n.t('Public access'), + path: 'sharing.public', + component: PublicAccessValue, + }, + ], + default: ['name', 'sharing.public', 'lastUpdated'], + }, + filters: { + available: ['name'], + default: ['name'], + }, +} 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. + +const modelListViewsConfig = { + dataElement: { + columns: { + available: ['zeroIsSignificant', 'categoryCombo'], + default: [ + 'name', + { label: i18n.t('Domain'), path: 'domainType' }, + { label: i18n.t('Value'), path: 'valueType' }, + 'lastUpdated', + 'sharing.public', + ], + }, + filters: { + default: ['name', 'domainType', 'valueType'], + available: ['zeroIsSignificant', 'categoryCombo'], + }, + }, +} satisfies SectionListViewConfig + +const toModelPropertyDescriptor = ( + propertyConfig: ModelPropertyConfig, + available?: ModelPropertyDescriptor[] +): ModelPropertyDescriptor => { + if (typeof propertyConfig === 'string') { + // simple descriptors can refer to previously defined descriptors + const availableDescriptor = available?.find( + (prop) => prop.path === propertyConfig + ) + + return ( + availableDescriptor || { + label: getTranslatedProperty(propertyConfig), + path: propertyConfig, + } + ) + } + return propertyConfig +} + +const resolveViewPart = (part: ViewConfigPart, type: keyof ViewConfig) => { + const mergedAvailableDescriptors = uniqueBy( + [ + part.available || [], + part.overrideDefaultAvailable + ? [] + : defaultModelViewConfig[type].available, + part.default || [], + ] + .flat() + .map((propConfig) => toModelPropertyDescriptor(propConfig)), + (prop) => prop.path + ) + const defaultPropConfig = + part.default || defaultModelViewConfig[type].default + const defaultDescriptors = defaultPropConfig.map((propConfig) => + toModelPropertyDescriptor(propConfig, mergedAvailableDescriptors) + ) + return { + available: mergedAvailableDescriptors, + default: defaultDescriptors, + } +} +// merge the default modelViewConfig with the modelViewsConfig for each section +const resolveListViewsConfig = (): SectionListViewConfig => { + const merged: SectionListViewConfig = {} + + Object.entries(modelListViewsConfig).forEach((viewConfig) => { + const [sectionName, sectionViewConfig] = viewConfig + merged[sectionName as SectionName] = { + columns: resolveViewPart(sectionViewConfig.columns, 'columns'), + filters: resolveViewPart(sectionViewConfig.filters, 'filters'), + } + }) + return merged +} + +const mergedModelViewsConfig = resolveListViewsConfig() +const resolvedDefaultConfig = { + columns: resolveViewPart(defaultModelViewConfig.columns, 'columns'), + filters: resolveViewPart(defaultModelViewConfig.filters, 'filters'), +} + +export const getViewConfigForSection = ( + sectionName: string +): ResolvedViewConfig => { + if (mergedModelViewsConfig[sectionName]) { + return mergedModelViewsConfig[sectionName] as ResolvedViewConfig + } + return resolvedDefaultConfig +} + +export const getColumnsForSection = ( + sectionName: string +): ResolvedViewConfig['columns'] => { + const view = getViewConfigForSection(sectionName) + return view.columns +} diff --git a/src/constants/sections.ts b/src/constants/sections.ts index d114180c..63c78596 100644 --- a/src/constants/sections.ts +++ b/src/constants/sections.ts @@ -380,6 +380,14 @@ export const SECTIONS_MAP = { ...NON_SCHEMA_SECTION, } as const satisfies SectionMap +export const sectionNames = new Set( + Object.values(SECTIONS_MAP).map((section) => section.name) +) + +export type SectionKey = keyof typeof SECTIONS_MAP +export type SectionName = + (typeof SECTIONS_MAP)[keyof typeof SECTIONS_MAP]['name'] + export const isSchemaSection = (section: Section): section is SchemaSection => { const schema = (SCHEMA_SECTIONS as SectionMap)[section.name] return schema !== undefined && !!schema.parentSectionKey diff --git a/src/constants/translatedModelProperties.ts b/src/constants/translatedModelProperties.ts new file mode 100644 index 00000000..3289d314 --- /dev/null +++ b/src/constants/translatedModelProperties.ts @@ -0,0 +1,39 @@ +import i18n from '@dhis2/d2-i18n' + +const TRANSLATED_PROPERTY: Record = { + categoryCombo: i18n.t('Category combination'), + code: i18n.t('Code'), + createdBy: i18n.t('Created by'), + favorite: i18n.t('Favorite'), + href: i18n.t('Href'), + id: i18n.t('Id'), + lastUpdatedBy: i18n.t('Last updated by'), + created: i18n.t('Created'), + domainType: i18n.t('Domain type'), + lastUpdated: i18n.t('Last updated'), + name: i18n.t('Name'), + sharing: i18n.t('Sharing'), + shortName: i18n.t('Short name'), + valueType: i18n.t('Value type'), + + user: i18n.t('Owner'), // user refers to the owner of the object + zeroIsSignificant: i18n.t('Zero is significant'), +} + +const camelCaseToSentenceCase = (camelCase: string) => + camelCase + .replace(/([A-Z])/g, (str) => ` ${str.toLowerCase()}`) + .replace(/^./, (str) => str.toUpperCase()) + +const markNotTranslated = (property: string) => + `** ${camelCaseToSentenceCase(property)} **` + +export const getTranslatedProperty = (property: string) => { + if (i18n.exists(property)) { + return i18n.t(property) + } + if (property in TRANSLATED_PROPERTY) { + return TRANSLATED_PROPERTY[property] + } + return markNotTranslated(property) +} diff --git a/src/lib/dataStore/DataStore.ts b/src/lib/dataStore/DataStore.ts new file mode 100644 index 00000000..314dbe15 --- /dev/null +++ b/src/lib/dataStore/DataStore.ts @@ -0,0 +1 @@ +export class DataStore {} diff --git a/src/lib/dataStore/index.ts b/src/lib/dataStore/index.ts new file mode 100644 index 00000000..a0f706b5 --- /dev/null +++ b/src/lib/dataStore/index.ts @@ -0,0 +1 @@ +export { useDataStoreValues } from './useDataStore' diff --git a/src/lib/dataStore/useDataStore.ts b/src/lib/dataStore/useDataStore.ts new file mode 100644 index 00000000..630b285e --- /dev/null +++ b/src/lib/dataStore/useDataStore.ts @@ -0,0 +1,133 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import { + useQuery, + useMutation, + useQueryClient, + QueryFunctionContext, +} from 'react-query' +import { Query } from '../../types' + +// types not exported from app-runtime... +type DataEngine = ReturnType +type Mutation = Parameters[0] +type GetMutationTypeUnion = { + [Type in Mutation as MutationType extends Type['type'] + ? 'type' + : never]: Type['type'] +} +type UpdateMutationTypeUnion = GetMutationTypeUnion<'update'> +type UpdateMutation = Extract +type UpdateMutationData = UpdateMutation['data'] + +type DataStoreOptions = { + namespace: string + key?: string + global?: boolean +} + +type ObjectResult = Record + +const createBoundQueryFn = + (engine: DataEngine) => + ({ queryKey: [query], signal }: QueryFunctionContext<[Query]>) => + engine.query(query, { signal }) as Promise // engine.query is not generic... + +const getDataStoreResource = (global?: boolean) => + global ? 'dataStore' : 'userDataStore' + +type NamespaceOptions = Pick + +type ValuesOptions = NamespaceOptions & { key: string } + +type SetValuesOptions = ValuesOptions & { + data: ResultType +} + +export const queryCreators = { + getKeys: ({ namespace, global }: NamespaceOptions) => ({ + result: { + resource: `${getDataStoreResource(global)}`, + id: namespace, + }, + }), + getValues: ({ namespace, global, key }: ValuesOptions) => ({ + result: { + resource: `${getDataStoreResource(global)}`, + id: `${namespace}/${key}`, + }, + }), + setValues: ({ + namespace, + global, + key, + data, + }: SetValuesOptions): UpdateMutation => ({ + resource: `${getDataStoreResource(global)}`, + id: `${namespace}/${key}`, + type: 'update', + // engine enforces data to be an object with keys, but can actually store any JSON-value + data: data as UpdateMutationData, + }), +} + +const selectIdentity = (data: TData) => data + +const defaultOptions = { + global: false, + select: selectIdentity, +} + +type UseDataStoreValuesOptions = ValuesOptions & { + placeholderData?: ResultType + select?: (data: ResultType) => SelectResult + enabled?: boolean +} +export function useDataStoreValues< + ResultType = ObjectResult, + SelectResult = ResultType +>(options: UseDataStoreValuesOptions) { + const mergedOptions = { + ...defaultOptions, + ...options, + } + const select = mergedOptions.select + const engine = useDataEngine() + const query = queryCreators.getValues(mergedOptions) + + const placeholderData = mergedOptions.placeholderData + ? { result: mergedOptions.placeholderData } + : undefined + + return useQuery({ + queryKey: [query], + queryFn: createBoundQueryFn(engine), + placeholderData, + // hide ".result" from consumer + select: (data) => select(data.result) as SelectResult, + }) +} + +export const useMutateDataStoreValues = (options: ValuesOptions) => { + const mergedOptions = { + ...defaultOptions, + ...options, + } + + const queryClient = useQueryClient() + const engine = useDataEngine() + const valuesQueryKey = [queryCreators.getValues(mergedOptions)] + const mutationFn = async (data: ObjectResult) => { + const mutation = queryCreators.setValues({ + namespace: mergedOptions.namespace, + key: mergedOptions.key, + global: mergedOptions.global, + data, + }) + return await engine.mutate(mutation) + } + const mutation = useMutation({ + mutationFn, + onSettled: () => queryClient.invalidateQueries(valuesQueryKey), + }) + return mutation +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 84bdc0f0..1312232d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -9,3 +9,4 @@ export * from './useDebounce' export * from './routeUtils' export * from './date' export * from './systemSettings' +export * from './utils' diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index 4af8856b..bcdb99fd 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -7,3 +7,4 @@ export type { } from './useModelGist' export { isValidUid } from './uid' export { parsePublicAccessString } from './parsePublicAccess' +export { getIn, stringToPathArray, getFieldFilterFromPath } from './path' diff --git a/src/lib/models/path.spec.ts b/src/lib/models/path.spec.ts new file mode 100644 index 00000000..1e39d97f --- /dev/null +++ b/src/lib/models/path.spec.ts @@ -0,0 +1,150 @@ +import { getIn, getFieldFilterFromPath } from './path' + +describe('path', () => { + describe('getIn', () => { + it('should return the value at the specified nested path', () => { + const obj = { + user: { + name: 'John', + address: { + city: 'New York', + zip: '10001', + }, + }, + } + + const result = getIn(obj, 'user.address.city') + expect(result).toBe('New York') + }) + + it('should return undefined for non-existent paths', () => { + const obj = { + user: { + name: 'John', + }, + } + + const result = getIn(obj, 'user.address.city') + expect(result).toBeUndefined() + }) + + it('should handle an array path', () => { + const obj = { + user: { + name: 'John', + address: { + city: 'New York', + zip: '10001', + }, + }, + } + + const result = getIn(obj, ['user', 'address', 'zip']) + expect(result).toBe('10001') + }) + + it('should handle null and undefined objects gracefully', () => { + const obj = null + + const result = getIn(obj, 'user.address.city') + expect(result).toBeUndefined() + }) + + it('should handle null and undefined properties gracefully', () => { + const obj = { + user: { + name: 'John', + address: null, + }, + } + + const result = getIn(obj, 'user.address.city') + expect(result).toBeUndefined() + }) + }) + + describe('getFieldFilterFromPath', () => { + it('should return the path as is for a single part', () => { + const path = 'name' + const result = getFieldFilterFromPath(path) + expect(result).toBe('name') + }) + + it('should return the path with square brackets for nested paths', () => { + const pathArr = ['user', 'address', 'city'] + const path = 'user.address.city' + const result = getFieldFilterFromPath(path) + expect(result).toBe('user[address[city]]') + + const resultArr = getFieldFilterFromPath(pathArr) + expect(resultArr).toBe(result) + }) + + it('should handle a nested path with a single part', () => { + const path = 'user.age' + const result = getFieldFilterFromPath(path) + expect(result).toBe('user[age]') + + const pathArr = ['user', 'age'] + const resultArr = getFieldFilterFromPath(pathArr) + expect(resultArr).toBe(result) + }) + + it('should handle a deeply nested path', () => { + const path = 'a.b.c.d.e' + const result = getFieldFilterFromPath(path) + expect(result).toBe('a[b[c[d[e]]]]') + + const pathArr = ['a', 'b', 'c', 'd', 'e'] + const resultArr = getFieldFilterFromPath(pathArr) + expect(resultArr).toBe(result) + }) + + it('should handle a path with a single part enclosed in square brackets', () => { + const path = '[user]' + const result = getFieldFilterFromPath(path) + expect(result).toBe('[user]') + }) + + it('should handle an empty path', () => { + const path: string[] = [] + const result = getFieldFilterFromPath(path) + expect(result).toBe('') + }) + + it('should handle a path with empty string parts', () => { + const path = ['', 'user', '', 'address', 'city'] + const result = getFieldFilterFromPath(path) + expect(result).toBe('user[address[city]]') + }) + + it('should drop nested fields if maxDepth is 0', () => { + const path = 'sharing.public' + const result = getFieldFilterFromPath(path, 0) + expect(result).toBe('sharing') + + const pathArr = ['sharing', 'public'] + const resultArr = getFieldFilterFromPath(pathArr, 0) + expect(resultArr).toBe(result) + }) + + it('should drop nested fields according to maxDepth', () => { + const path = 'sharing.public' + + const result = getFieldFilterFromPath(path, 1) + + expect(result).toBe('sharing[public]') + + const deCatComboPath = 'dataElement.categoryCombo.id' + const resultDepth1 = getFieldFilterFromPath(deCatComboPath, 1) + + expect(resultDepth1).toBe('dataElement[categoryCombo]') + + const resultDepth2 = getFieldFilterFromPath(deCatComboPath, 2) + expect(resultDepth2).toBe('dataElement[categoryCombo[id]]') + + const resultDepth3 = getFieldFilterFromPath(deCatComboPath, 3) + expect(resultDepth3).toBe('dataElement[categoryCombo[id]]') + }) + }) +}) diff --git a/src/lib/models/path.ts b/src/lib/models/path.ts new file mode 100644 index 00000000..14627148 --- /dev/null +++ b/src/lib/models/path.ts @@ -0,0 +1,57 @@ +export const stringToPathArray = (str: string): string[] => str.split('.') + +const resolvePath = (path: string | string[]): string[] => { + return typeof path === 'string' + ? stringToPathArray(path) + : path.filter((p) => !!p) // filter out empty strings +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const getIn = (object: any, path: string | string[]) => { + const pathArray = resolvePath(path) + + let current = object + for (const prop of pathArray) { + if (current == null || current[prop] == null) { + return undefined + } + current = current[prop] + } + return current +} + +/** + * Transforms a path like dataElement.id into a field filter of the form `dataElement[id]` + * @param path the path to transform, a dot-delimited string or an array of strings + * @param maxDepth the maximum number of nested fields to allow. If the path is deeper than this, the parts after the depth will be dropped. + * Set this to 0 to create field-filters without the nested parts. Can be useful if the API does not support nested field-filters + * for a particular request. + * @returns + */ +export const getFieldFilterFromPath = ( + path: string | string[], + maxDepth = 10 +): string => { + const recur = (path: string[], depth: number): string => { + const pathParts = resolvePath(path) + + if (pathParts.length === 0) { + return '' + } + if (pathParts.length === 1) { + return pathParts[0] + } + + const [currentPart, ...rest] = pathParts + + if (depth >= maxDepth) { + return currentPart + } + + const nestedFilter = recur(rest, depth + 1) + + return `${currentPart}[${nestedFilter}]` + } + + return recur(resolvePath(path), 0) +} diff --git a/src/lib/routeUtils/useSectionHandle.ts b/src/lib/routeUtils/useSectionHandle.ts index 8d0f447b..5f517a64 100644 --- a/src/lib/routeUtils/useSectionHandle.ts +++ b/src/lib/routeUtils/useSectionHandle.ts @@ -1,6 +1,7 @@ import { useMatches } from 'react-router-dom' import { MatchRouteHandle } from '../../app/routes/types' -import { SchemaSection, Section } from '../../types' +import { isOverviewSection, isSchemaSection } from '../../constants' +import { ModelSection, SchemaSection, Section } from '../../types' export const useSectionHandle = (): Section | undefined => { const matches = useMatches() as MatchRouteHandle[] @@ -9,12 +10,20 @@ export const useSectionHandle = (): Section | undefined => { return match?.handle?.section } +export const useModelSectionHandleOrThrow = (): ModelSection => { + const section = useSectionHandle() + + if (!section || isOverviewSection(section)) { + throw new Error('Could not find model section handle') + } + + return section +} + export const useSchemaSectionHandleOrThrow = (): SchemaSection => { - const matches = useMatches() as MatchRouteHandle[] - const match = matches.find((routeMatch) => routeMatch.handle?.section) + const section = useSectionHandle() - const section = match?.handle?.section - if (!section) { + if (!section || !isSchemaSection(section)) { throw new Error('Could not find schema section handle') } return section diff --git a/src/lib/user/index.ts b/src/lib/user/index.ts index 98f4e126..fd647932 100644 --- a/src/lib/user/index.ts +++ b/src/lib/user/index.ts @@ -4,3 +4,4 @@ export { useCurrentUserAuthorities, } from './currentUserStore' export * from './authorities' +// asf diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 00000000..a20a8417 --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1 @@ +export { uniqueBy } from './uniqueBy' diff --git a/src/lib/utils/uniqueBy.spec.ts b/src/lib/utils/uniqueBy.spec.ts new file mode 100644 index 00000000..26a768f1 --- /dev/null +++ b/src/lib/utils/uniqueBy.spec.ts @@ -0,0 +1,68 @@ +import { uniqueBy } from './uniqueBy' + +describe('uniqueBy', () => { + it('should return an array with unique items based on the provided transformer function', () => { + const inputArray = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + { id: 3, name: 'John' }, + { id: 4, name: 'Alice' }, + ] + + const result = uniqueBy(inputArray, (item) => item.name) + + expect(result).toEqual([ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + { id: 4, name: 'Alice' }, + ]) + }) + + it('should handle an empty input array', () => { + const inputArray: any[] = [] + + const result = uniqueBy(inputArray, (item) => item.name) + + expect(result).toEqual([]) + }) + + it('should handle an input array with only one item', () => { + const inputArray = [{ id: 1, name: 'John' }] + + const result = uniqueBy(inputArray, (item) => item.name) + + expect(result).toEqual([{ id: 1, name: 'John' }]) + }) + + it('should handle an input array with duplicate items', () => { + const inputArray = [ + { id: 1, name: 'John' }, + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ] + + const result = uniqueBy(inputArray, (item) => item.id) + + expect(result).toEqual([ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ]) + }) + + it('should maintain stable ordering when encountering duplicates', () => { + const inputArray = [ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + { id: 3, name: 'John' }, + { id: 4, name: 'Alice' }, + ] + + const result = uniqueBy(inputArray, (item) => item.name) + + expect(result).toEqual([ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + { id: 4, name: 'Alice' }, + ]) + }) +}) diff --git a/src/lib/utils/uniqueBy.ts b/src/lib/utils/uniqueBy.ts new file mode 100644 index 00000000..cd9c3d7a --- /dev/null +++ b/src/lib/utils/uniqueBy.ts @@ -0,0 +1,16 @@ +export const uniqueBy = ( + array: ReadonlyArray, + transformer: (item: T) => K +): T[] => { + const set = new Set() + + const uniqueArr: T[] = [] + for (const item of array) { + const key = transformer(item) + if (!set.has(key)) { + set.add(key) + uniqueArr.push(item) + } + } + return uniqueArr +} diff --git a/src/pages/dataElements/List.spec.tsx b/src/pages/dataElements/List.spec.tsx index 1bcc6dbd..6a9cb80a 100644 --- a/src/pages/dataElements/List.spec.tsx +++ b/src/pages/dataElements/List.spec.tsx @@ -9,7 +9,7 @@ import React from 'react' import dataElementsMock from '../../__mocks__/gists/dataElementsMock.json' import filteredDataElementsMock from '../../__mocks__/gists/filteredDataElementsMock.json' import dataElementSchemaMock from '../../__mocks__/schema/dataElementsSchema.json' -import { OVERVIEW_SECTIONS } from '../../constants' +import { SECTIONS_MAP } from '../../constants' import { useSchemaStore } from '../../lib/schemas/schemaStore' import { ModelSchemas } from '../../lib/useLoadApp' import TestComponentWithRouter, { @@ -19,7 +19,7 @@ import { Component as DataElementList } from './List' const renderSection = async (customData: CustomData) => { const routeOptions = { - handle: { section: OVERVIEW_SECTIONS.dataElement }, + handle: { section: SECTIONS_MAP.dataElement }, } const result = render( @@ -53,6 +53,7 @@ describe('Data Elements List', () => { it('should show the list of elements', async () => { const customData = { 'dataElements/gist': dataElementsMock, + userDataStore: {}, } const { getByText, getByTestId } = await renderSection(customData) @@ -69,6 +70,7 @@ describe('Data Elements List', () => { it('should display all the columns', async () => { const customData = { 'dataElements/gist': dataElementsMock, + userDataStore: {}, } const { getByText } = await renderSection(customData) const columns = [ @@ -85,6 +87,7 @@ describe('Data Elements List', () => { }) it('should allow searching for value', async () => { const customData = { + userDataStore: {}, 'dataElements/gist': ( resource: string, r: { params: { filter: string[] } } @@ -115,6 +118,7 @@ describe('Data Elements List', () => { it('should display error when an API call fails', async () => { const customData = { + userDataStore: {}, 'dataElements/gist': () => { return Promise.reject('401 backend error') }, @@ -132,6 +136,7 @@ describe('Data Elements List', () => { const renderWithPager = async () => { const customData = { + userDataStore: {}, 'dataElements/gist': ( resource: string, r: { params: { filter: string[]; page: number } } @@ -235,6 +240,7 @@ describe('Data Elements List', () => { // I tried different approaches and failed. Leaving it here temporarily in case someone want to give it a go. it.skip('should not show next in last page', async () => { const { getByTestId, findByText } = await renderSection({ + userDataStore: {}, 'dataElements/gist': { pager: { page: 54, @@ -269,6 +275,7 @@ describe('Data Elements List', () => { // select all it('should allow selecting all items', async () => { const customData = { + userDataStore: {}, 'dataElements/gist': dataElementsMock, } const { getByTestId, queryAllByTestId } = await renderSection( @@ -291,6 +298,7 @@ describe('Data Elements List', () => { // empty list it('should allow selecting all items', async () => { const customData = { + userDataStore: {}, 'dataElements/gist': { ...dataElementsMock, result: [] }, } const { getByTestId } = await renderSection(customData) diff --git a/src/pages/dataElements/List.tsx b/src/pages/dataElements/List.tsx index 4a70ac07..faa50393 100644 --- a/src/pages/dataElements/List.tsx +++ b/src/pages/dataElements/List.tsx @@ -1,14 +1,13 @@ -import i18n from '@dhis2/d2-i18n' -import React from 'react' +import React, { useEffect } from 'react' import { SectionListWrapper, - SelectedColumns, DomainTypeSelectionFilter, ValueTypeSelectionFilter, useQueryParamsForModelGist, - useSectionListParamsRefetch, } from '../../components' -import { useModelGist } from '../../lib/' +import { GenericSelectionFilter } from '../../components/sectionList/filters/GenericSelectionFilter' +import { useModelListView } from '../../components/sectionList/listView' +import { getFieldFilterFromPath, useModelGist } from '../../lib/' import { DataElement, GistCollectionResponse } from '../../types/models' const filterFields = [ @@ -26,35 +25,41 @@ type FilteredDataElement = Pick type DataElements = GistCollectionResponse -const defaulHeaderColumns: SelectedColumns = [ - { - modelPropertyName: 'name', - label: i18n.t('Name'), - }, - { modelPropertyName: 'domainType', label: i18n.t('Domain') }, - { modelPropertyName: 'valueType', label: i18n.t('Value') }, - { modelPropertyName: 'lastUpdated', label: i18n.t('Last updated') }, - { modelPropertyName: 'sharing', label: i18n.t('Public access') }, -] - export const Component = () => { + const { columns, query: listViewQuery } = useModelListView() const initialParams = useQueryParamsForModelGist() + const { refetch, error, data } = useModelGist( 'dataElements/gist', { fields: filterFields.concat(), ...initialParams, }, - // refetched on mount by useSectionListParamsRefetch below + // refetched on mount by effect below { lazy: true } ) - - useSectionListParamsRefetch(refetch) + useEffect(() => { + // wait to fetch until selected-columns are loaded + // so we dont fetch data multiple times + if (listViewQuery.isLoading) { + return + } + refetch({ + ...initialParams, + fields: columns + .map((column) => getFieldFilterFromPath(column.path, 0)) + .concat('id'), + }) + }, [refetch, initialParams, columns, listViewQuery.isLoading]) return (
+ diff --git a/src/types/generated/utility.ts b/src/types/generated/utility.ts index 76206d0b..8a91db6d 100644 --- a/src/types/generated/utility.ts +++ b/src/types/generated/utility.ts @@ -1,10 +1,23 @@ /* GENERATED BY https://github.com/Birkbjo/dhis2-open-api-ts */ -import { IdentifiableObject, GistPager } from './' +import { IdentifiableObject, GistPager, Pager } from './' // import { CategoryCombo, DataElement } from "../generated"; type ModelReferenceCollection = Array type ModelReference = IdentifiableObject | ModelReferenceCollection +export type ModelCollectionPart< + T extends IdentifiableObject, + PagedListName extends string = 'result' +> = { + [K in PagedListName]: T[] +} +export type ModelCollectionResponse< + T extends IdentifiableObject = IdentifiableObject, + PagedListName extends string = 'result' +> = { + pager: Pager +} & ModelCollectionPart + type BaseGist = IdentifiableObject & { apiEndpoints: GistApiEndpoints } diff --git a/yarn.lock b/yarn.lock index 541c8127..7ba44a18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14141,6 +14141,11 @@ zip-stream@^2.1.2: compress-commons "^2.1.1" readable-stream "^3.4.0" +zod@^3.22.2: + version "3.22.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b" + integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg== + zustand@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.0.tgz#13b3e8ca959dd53d536034440aec382ff91b65c3"