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 40c191ed..fc9d9f30 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -108,12 +108,39 @@ 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 "At least one column must be selected" +msgstr "At least one column must be selected" + +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 "Failed to save" +msgstr "Failed to save" + +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" @@ -123,6 +150,15 @@ msgstr "Public can view" msgid "Public cannot access" msgstr "Public cannot access" +msgid "Public access" +msgstr "Public access" + +msgid "Domain" +msgstr "Domain" + +msgid "Value" +msgstr "Value" + msgid "Category" msgstr "Category" @@ -177,9 +213,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" @@ -519,29 +552,56 @@ msgstr "Image" msgid "GeoJSON" msgstr "GeoJSON" -msgid "Something went wrong when submitting the form" -msgstr "Something went wrong when submitting the form" +msgid "Code" +msgstr "Code" -msgid "Cancel" -msgstr "Cancel" +msgid "Created by" +msgstr "Created by" -msgid "Save and close" -msgstr "Save and close" +msgid "Favorite" +msgstr "Favorite" -msgid "Name" -msgstr "Name" +msgid "Href" +msgstr "Href" -msgid "Domain" -msgstr "Domain" +msgid "Id" +msgstr "Id" -msgid "Value" -msgstr "Value" +msgid "Last updated by" +msgstr "Last updated by" + +msgid "Created" +msgstr "Created" + +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 "Something went wrong when submitting the form" +msgstr "Something went wrong when submitting the form" + +msgid "Save and close" +msgstr "Save and close" msgid "Exit without saving" msgstr "Exit without saving" @@ -567,9 +627,6 @@ msgstr "Short name" msgid "Often used in reports where space is limited" msgstr "Often used in reports where space is limited" -msgid "Code" -msgstr "Code" - msgid "Description" msgstr "Description" diff --git a/package.json b/package.json index e0ab18ae..aa8b421e 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,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/layout/Breadcrumb.spec.tsx b/src/app/layout/Breadcrumb.spec.tsx index 18947263..bbcbe628 100644 --- a/src/app/layout/Breadcrumb.spec.tsx +++ b/src/app/layout/Breadcrumb.spec.tsx @@ -2,7 +2,7 @@ import '@testing-library/jest-dom' import { configure, render } from '@testing-library/react' import React from 'react' import { useMatches, HashRouter } from 'react-router-dom' -import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../constants/sections' +import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../lib' import { MatchRouteHandle, RouteHandle } from '../routes/types' import { Breadcrumbs, BreadcrumbItem } from './Breadcrumb' diff --git a/src/app/layout/Breadcrumb.tsx b/src/app/layout/Breadcrumb.tsx index e8d9b89d..6d2c7646 100644 --- a/src/app/layout/Breadcrumb.tsx +++ b/src/app/layout/Breadcrumb.tsx @@ -1,8 +1,12 @@ import React from 'react' import { Link, useMatches } from 'react-router-dom' -import { Section, isOverviewSection } from '../../constants' -import { getSectionPath, getOverviewPath } from '../../lib' -import { MatchRouteHandle } from '../routes/types' +import { + Section, + isOverviewSection, + getSectionPath, + getOverviewPath, +} from '../../lib' +import type { MatchRouteHandle } from '../routes/types' import css from './Breadcrumb.module.css' const BreadcrumbSeparator = () => / diff --git a/src/app/routes/LegacyAppRedirect.tsx b/src/app/routes/LegacyAppRedirect.tsx index 8c96f933..43fec479 100644 --- a/src/app/routes/LegacyAppRedirect.tsx +++ b/src/app/routes/LegacyAppRedirect.tsx @@ -3,7 +3,7 @@ import i18n from '@dhis2/d2-i18n' import { NoticeBox, Button } from '@dhis2/ui' import React from 'react' import { useParams, Params } from 'react-router-dom' -import { isOverviewSection, Section, SECTIONS_MAP } from '../../constants' +import { isOverviewSection, Section, SECTIONS_MAP } from '../../lib' const legacyPath = '/dhis-web-maintenance/index.html#/' diff --git a/src/app/routes/Router.tsx b/src/app/routes/Router.tsx index 37888ade..15798886 100644 --- a/src/app/routes/Router.tsx +++ b/src/app/routes/Router.tsx @@ -18,8 +18,6 @@ import { Section, SchemaSection, OVERVIEW_SECTIONS, -} from '../../constants' -import { getSectionPath, isModuleNotFoundError, isValidUid, diff --git a/src/app/routes/types.ts b/src/app/routes/types.ts index 4aafe852..e280b69f 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,8 +9,8 @@ type MatchWithHandle = ReturnType[number] & { // common type for possible handle-properties used in Route export type RouteHandle = { hideSidebar?: boolean + section?: ModelSection showFooter?: boolean - section?: SchemaSection crumb?: () => React.ReactNode } diff --git a/src/app/sidebar/SidebarLinks.ts b/src/app/sidebar/SidebarLinks.ts index d1df7ccc..08456f12 100644 --- a/src/app/sidebar/SidebarLinks.ts +++ b/src/app/sidebar/SidebarLinks.ts @@ -4,8 +4,6 @@ import { OVERVIEW_SECTIONS, SECTIONS_MAP, Section, -} from '../../constants/sections' -import { getOverviewPath, getSectionPath, useIsSectionAuthorizedPredicate, 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..c4e91a58 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,15 @@ export const SectionListWrapper = ({ { + renderColumnValue={({ path }) => { return ( ) }} diff --git a/src/components/sectionList/SelectionListHeaderNormal.tsx b/src/components/sectionList/SelectionListHeaderNormal.tsx index 585922ca..5603d49d 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 '../../lib' +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/ConstantFilters.tsx b/src/components/sectionList/filters/ConstantFilters.tsx index 4dacfef1..635b0340 100644 --- a/src/components/sectionList/filters/ConstantFilters.tsx +++ b/src/components/sectionList/filters/ConstantFilters.tsx @@ -1,8 +1,5 @@ import React from 'react' -import { - DOMAIN_TYPE, - VALUE_TYPE, -} from '../../../constants/translatedModelConstants' +import { DOMAIN_TYPE, VALUE_TYPE } from '../../../lib' import { ConstantSelectionFilter } from './ConstantSelectionFilter' export const DomainTypeSelectionFilter = () => { 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..70ced1f6 --- /dev/null +++ b/src/components/sectionList/listView/ManageListView.tsx @@ -0,0 +1,135 @@ +import { FetchError } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { Button, Field, NoticeBox, Transfer } from '@dhis2/ui' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { + getColumnsForSection, + 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 [error, setError] = useState() + const [saveError, setSaveError] = useState() + + 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 = () => { + if (pendingSelectedColumns.length < 1) { + setError(i18n.t('At least one column must be selected')) + return + } + saveColumns(pendingSelectedColumns, { + onSuccess: () => onSaved(), + onError: (error) => { + if (error instanceof FetchError) { + setSaveError(error) + } + }, + }) + } + + const handleChange = ({ selected }: { selected: string[] }) => { + isTouched.current = true + + setPendingSelectedColumns(selected) + setError(undefined) + setSaveError(undefined) + } + + const handleSetDefault = () => { + handleChange({ selected: columnsConfig.default.map(toPath) }) + } + + 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} + /> + + + {saveError && ( +

+ + {saveError.message} + +

+ )} + {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..49b4a1e4 --- /dev/null +++ b/src/components/sectionList/listView/types.ts @@ -0,0 +1,13 @@ +import { SectionName } from '../../../lib' +import type { ModelPropertyDescriptor } from '../../../lib' + +export interface ModelListView { + name: string + sectionModel: string + columns: ReadonlyArray + filters: ReadonlyArray +} + +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..f942fdd7 --- /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, + useModelSectionHandleOrThrow, + useDataStoreValuesQuery, + queryCreators, + useMutateDataStoreValuesQuery, +} from '../../../lib' +import type { ModelListView } from './types' + +const maintenanceNamespace = 'maintenance' +const configurableColumnsKey = 'modelListViews' + +const valuesQueryKey = [ + queryCreators.getValues({ + namespace: maintenanceNamespace, + key: configurableColumnsKey, + }), +] + +const dataStoreModelListViewSchema = z.object({ + name: z.string(), + sectionModel: z.string(), + columns: z.array(z.string()), + filters: z.array(z.string()), +}) + +type DataStoreModelListView = 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(dataStoreModelListViewSchema).length(1) + ) + .refine((val) => Object.keys(val).length > 0) + +type DataStoreModelListViews = 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: DataStoreModelListView, + name: string +): ModelListView => { + const listView = dataStoreModelListViewSchema.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 +} + +// selects the specific section from the result, based on sectionName +// check that columns are valid - because data in dataStore should not +// be trusted - since there's no validation server-side. +const createValidViewSelect = (sectionName: string) => { + return (data: DataStoreModelListViews): 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 = useDataStoreValuesQuery({ + namespace: maintenanceNamespace, + key: configurableColumnsKey, + // selects the specific section from the result + select, + }) + + // 404 errors are expected when user havent saved any views + if (query.error && (query.error as any).details?.httpStatusCode !== 404) { + 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 = useMutateDataStoreValuesQuery({ + 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: DataStoreModelListViews = { + ...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/ConstantValue.tsx b/src/components/sectionList/modelValue/ConstantValue.tsx index 569beff2..2ccc2074 100644 --- a/src/components/sectionList/modelValue/ConstantValue.tsx +++ b/src/components/sectionList/modelValue/ConstantValue.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { getConstantTranslation } from '../../../constants/translatedModelConstants' +import { getConstantTranslation } from '../../../lib' export const ConstantValue = ({ value }: { value?: string }) => ( {getConstantTranslation(value || '')} diff --git a/src/components/sectionList/modelValue/ModelValue.tsx b/src/components/sectionList/modelValue/ModelValue.tsx index c7fbfbf8..89444906 100644 --- a/src/components/sectionList/modelValue/ModelValue.tsx +++ b/src/components/sectionList/modelValue/ModelValue.tsx @@ -1,40 +1,56 @@ 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 + sectionModel: unknown } const ModelValueError = () => { return Error } -export const ModelValue = ({ - schema, - modelPropertyName, - value, -}: ModelValueProps) => { - const schemaProperty = schema.properties[modelPropertyName] - - if (!schemaProperty) { - console.warn( - `Property ${modelPropertyName} not found in schema, value not rendered: ${value}` - ) +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 = ({ schema, path, sectionModel }: ModelValueProps) => { + const schemaProperty = getSchemaProperty(schema, path) + + const value = getIn(sectionModel, path) + + if (!schemaProperty || value == undefined) { + console.warn(`Property ${path} not found, value not rendered: ${value}`) return null } return ( - + ) } diff --git a/src/components/sectionList/modelValue/ModelValueRenderer.tsx b/src/components/sectionList/modelValue/ModelValueRenderer.tsx index 49d902a3..4339077a 100644 --- a/src/components/sectionList/modelValue/ModelValueRenderer.tsx +++ b/src/components/sectionList/modelValue/ModelValueRenderer.tsx @@ -1,13 +1,23 @@ 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 { PublicAccessValue } from './PublicAccess' import { TextValue } from './TextValue' -export const ModelValueRenderer = ({ value, schemaProperty }: ValueDetails) => { - if (schemaProperty.fieldName === 'sharing' && isSharing(value)) { +export type ValueDetails = { + schemaProperty: SchemaFieldProperty + value: unknown + path: string +} + +export const ModelValueRenderer = ({ + path, + value, + schemaProperty, +}: ValueDetails) => { + if (path === 'sharing.public' && typeof value === 'string') { 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..d3b33099 100644 --- a/src/components/sectionList/types.ts +++ b/src/components/sectionList/types.ts @@ -1,13 +1,9 @@ -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 = ReadonlyArray export type CheckBoxOnChangeObject = { checked: boolean diff --git a/src/constants/index.ts b/src/constants/index.ts deleted file mode 100644 index 87a60b0b..00000000 --- a/src/constants/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './sections' -export { - AGGREGATION_TYPE, - DOMAIN_TYPE, - VALUE_TYPE, -} from './translatedModelConstants' diff --git a/src/lib/constants/index.ts b/src/lib/constants/index.ts new file mode 100644 index 00000000..369de77f --- /dev/null +++ b/src/lib/constants/index.ts @@ -0,0 +1,4 @@ +export * from './sectionListViewsConfig' +export * from './sections' +export * from './translatedModelConstants' +export * from './translatedModelProperties' diff --git a/src/lib/constants/sectionListViewsConfig.ts b/src/lib/constants/sectionListViewsConfig.ts new file mode 100644 index 00000000..f20ad6e0 --- /dev/null +++ b/src/lib/constants/sectionListViewsConfig.ts @@ -0,0 +1,165 @@ +import i18n from '@dhis2/d2-i18n' +import { uniqueBy } from '../utils' +import type { SectionName } from './sections' +import { getTranslatedProperty } from './translatedModelProperties' + +export interface ModelPropertyDescriptor { + label: string + path: string +} + +type ModelPropertyConfig = string | ModelPropertyDescriptor +interface ViewConfigPart { + available?: ReadonlyArray + overrideDefaultAvailable?: boolean + default?: ReadonlyArray +} + +interface ViewConfig { + columns: ViewConfigPart + filters: ViewConfigPart +} + +interface ResolvedViewConfigPart { + available: ReadonlyArray + default: ReadonlyArray +} +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', + }, + ], + 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/lib/constants/sections.ts similarity index 98% rename from src/constants/sections.ts rename to src/lib/constants/sections.ts index d114180c..afb16f11 100644 --- a/src/constants/sections.ts +++ b/src/lib/constants/sections.ts @@ -9,7 +9,7 @@ import { OverviewSectionMap, OverviewSection, NonSchemaSection, -} from '../types' +} from '../../types' // for convenience so types can be imported with the map below export type { SectionMap, Section, SchemaSection, SchemaSectionMap } @@ -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/translatedModelConstants.ts b/src/lib/constants/translatedModelConstants.ts similarity index 100% rename from src/constants/translatedModelConstants.ts rename to src/lib/constants/translatedModelConstants.ts diff --git a/src/lib/constants/translatedModelProperties.ts b/src/lib/constants/translatedModelProperties.ts new file mode 100644 index 00000000..3289d314 --- /dev/null +++ b/src/lib/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/index.ts b/src/lib/dataStore/index.ts new file mode 100644 index 00000000..12912c8e --- /dev/null +++ b/src/lib/dataStore/index.ts @@ -0,0 +1 @@ +export * from './useDataStore' diff --git a/src/lib/dataStore/useDataStore.ts b/src/lib/dataStore/useDataStore.ts new file mode 100644 index 00000000..9bf253f3 --- /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 type { 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 useDataStoreValuesQuery< + 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 useMutateDataStoreValuesQuery = (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 afe21cd4..0223926d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,8 +1,8 @@ +export * from './constants' export * from './debounce' export * from './models' export * from './schemas' -export { useLoadApp } from './useLoadApp' -export type { Schema } from './useLoadApp' +export * from './useLoadApp' export * from './errors' export * from './user' export * from './sections' @@ -10,3 +10,5 @@ export * from './useDebounce' export * from './routeUtils' export * from './date' export * from './systemSettings' +// export * from './utils' +export * from './dataStore' 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/routePaths.ts b/src/lib/routeUtils/routePaths.ts index 8bd4940d..6557fcd1 100644 --- a/src/lib/routeUtils/routePaths.ts +++ b/src/lib/routeUtils/routePaths.ts @@ -1,4 +1,4 @@ -import type { Section } from '../../constants/sections' +import type { Section } from '../../lib' export const routePaths = { overviewRoot: 'overview', diff --git a/src/lib/routeUtils/useSectionHandle.ts b/src/lib/routeUtils/useSectionHandle.ts index 8d0f447b..7082326a 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 { ModelSection, SchemaSection, Section } from '../../types' +import { isOverviewSection, isSchemaSection } from '../constants' 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/sections/sectionAuthorities.spec.ts b/src/lib/sections/sectionAuthorities.spec.ts index ba3d945d..5c5a57e6 100644 --- a/src/lib/sections/sectionAuthorities.spec.ts +++ b/src/lib/sections/sectionAuthorities.spec.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react-hooks' -import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../constants' import { SystemSettings } from '../../types' +import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../constants' import { useSchemaStore } from '../schemas/schemaStore' import { useSystemSettingsStore } from '../systemSettings/systemSettingsStore' import { ModelSchemas, CurrentUser } from '../useLoadApp' diff --git a/src/lib/sections/sectionAuthorities.ts b/src/lib/sections/sectionAuthorities.ts index 33460dc7..fb7c2406 100644 --- a/src/lib/sections/sectionAuthorities.ts +++ b/src/lib/sections/sectionAuthorities.ts @@ -1,12 +1,12 @@ import { useCallback } from 'react' +import { ModelSection } from '../../types' import { SECTIONS_MAP, isSchemaSection, Section, SchemaSection, isOverviewSection, -} from '../../constants' -import { ModelSection } from '../../types' +} from '../constants' import { useSchemas } from '../schemas' import { useSystemSetting } from '../systemSettings' import { ModelSchemas } from '../useLoadApp' 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/Edit.tsx b/src/pages/dataElements/Edit.tsx index ed7e61fd..767a2cdd 100644 --- a/src/pages/dataElements/Edit.tsx +++ b/src/pages/dataElements/Edit.tsx @@ -6,11 +6,10 @@ import React, { useEffect, useRef } from 'react' import { withTypes } from 'react-final-form' import { useNavigate, useParams } from 'react-router-dom' import { StandardFormActions, StandardFormSection } from '../../components' -import { SCHEMA_SECTIONS } from '../../constants' -import { getSectionPath } from '../../lib' +import { SCHEMA_SECTIONS, getSectionPath } from '../../lib' import { JsonPatchOperation } from '../../types' import { Attribute, DataElement } from '../../types/generated' -import { createJsonPatchOperations } from './edit' +import { createJsonPatchOperations } from './edit/' import classes from './Edit.module.css' import { DataElementFormFields, useCustomAttributesQuery } from './form' import { FormValues } from './form/types' diff --git a/src/pages/dataElements/List.spec.tsx b/src/pages/dataElements/List.spec.tsx index 1bcc6dbd..44c41b1c 100644 --- a/src/pages/dataElements/List.spec.tsx +++ b/src/pages/dataElements/List.spec.tsx @@ -1,3 +1,4 @@ +import { FetchError } from '@dhis2/app-runtime' import { render, waitForElementToBeRemoved, @@ -9,7 +10,8 @@ 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 { useModelListView } from '../../components/sectionList/listView' +import { SECTIONS_MAP } from '../../lib' import { useSchemaStore } from '../../lib/schemas/schemaStore' import { ModelSchemas } from '../../lib/useLoadApp' import TestComponentWithRouter, { @@ -19,7 +21,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( @@ -37,10 +39,19 @@ const renderSection = async (customData: CustomData) => { return result } +// userDataStore returns 404 if user hasnt edited a view, this is expected behaviour +const error404 = new FetchError({ + type: 'unknown', + message: '404 not found', + details: { httpStatusCode: 404 } as FetchError['details'], +}) +const defaultUserDataStoreData = () => Promise.reject(new FetchError(error404)) + describe('Data Elements List', () => { + const originalWarn = console.warn jest.spyOn(console, 'warn').mockImplementation((value) => { if (!value.match(/No server timezone/)) { - console.warn(value) + originalWarn(value) } }) @@ -53,6 +64,7 @@ describe('Data Elements List', () => { it('should show the list of elements', async () => { const customData = { 'dataElements/gist': dataElementsMock, + userDataStore: defaultUserDataStoreData, } const { getByText, getByTestId } = await renderSection(customData) @@ -69,6 +81,7 @@ describe('Data Elements List', () => { it('should display all the columns', async () => { const customData = { 'dataElements/gist': dataElementsMock, + userDataStore: defaultUserDataStoreData, } const { getByText } = await renderSection(customData) const columns = [ @@ -85,6 +98,7 @@ describe('Data Elements List', () => { }) it('should allow searching for value', async () => { const customData = { + userDataStore: defaultUserDataStoreData, 'dataElements/gist': ( resource: string, r: { params: { filter: string[] } } @@ -115,6 +129,7 @@ describe('Data Elements List', () => { it('should display error when an API call fails', async () => { const customData = { + userDataStore: defaultUserDataStoreData, 'dataElements/gist': () => { return Promise.reject('401 backend error') }, @@ -132,6 +147,7 @@ describe('Data Elements List', () => { const renderWithPager = async () => { const customData = { + userDataStore: defaultUserDataStoreData, 'dataElements/gist': ( resource: string, r: { params: { filter: string[]; page: number } } @@ -235,6 +251,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: defaultUserDataStoreData, 'dataElements/gist': { pager: { page: 54, @@ -269,6 +286,7 @@ describe('Data Elements List', () => { // select all it('should allow selecting all items', async () => { const customData = { + userDataStore: defaultUserDataStoreData, 'dataElements/gist': dataElementsMock, } const { getByTestId, queryAllByTestId } = await renderSection( @@ -291,6 +309,7 @@ describe('Data Elements List', () => { // empty list it('should allow selecting all items', async () => { const customData = { + userDataStore: defaultUserDataStoreData, '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..9a9ec85a 100644 --- a/src/pages/dataElements/List.tsx +++ b/src/pages/dataElements/List.tsx @@ -1,14 +1,12 @@ -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 { useModelListView } from '../../components/sectionList/listView' +import { getFieldFilterFromPath, useModelGist } from '../../lib/' import { DataElement, GistCollectionResponse } from '../../types/models' const filterFields = [ @@ -26,18 +24,8 @@ 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', @@ -45,16 +33,27 @@ export const Component = () => { 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/pages/dataElements/New.tsx b/src/pages/dataElements/New.tsx index a6ddf2ea..0a352f2f 100644 --- a/src/pages/dataElements/New.tsx +++ b/src/pages/dataElements/New.tsx @@ -6,8 +6,7 @@ import React, { useEffect, useRef } from 'react' import { Form } from 'react-final-form' import { useNavigate } from 'react-router-dom' import { StandardFormActions, StandardFormSection } from '../../components' -import { SCHEMA_SECTIONS } from '../../constants' -import { getSectionPath } from '../../lib' +import { SCHEMA_SECTIONS, getSectionPath } from '../../lib' import { Attribute } from '../../types/generated' import { DataElementFormFields, useCustomAttributesQuery } from './form' import { FormValues } from './form/types' diff --git a/src/pages/dataElements/form/customFields.tsx b/src/pages/dataElements/form/customFields.tsx index 57811fc5..757850e2 100644 --- a/src/pages/dataElements/form/customFields.tsx +++ b/src/pages/dataElements/form/customFields.tsx @@ -16,8 +16,12 @@ import { OptionSetSelect, LegendSetTransfer, } from '../../../components' -import { AGGREGATION_TYPE, DOMAIN_TYPE, VALUE_TYPE } from '../../../constants' -import { useSchemas } from '../../../lib' +import { + AGGREGATION_TYPE, + DOMAIN_TYPE, + VALUE_TYPE, + useSchemas, +} from '../../../lib' import classes from './customFields.module.css' import { EditableFieldWrapper } from './EditableFieldWrapper' diff --git a/src/pages/overview/Categories.tsx b/src/pages/overview/Categories.tsx index d4763fe4..a881d4b3 100644 --- a/src/pages/overview/Categories.tsx +++ b/src/pages/overview/Categories.tsx @@ -1,6 +1,6 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' -import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../constants' +import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../lib' import { FilterAuthorizedSections, SummaryCard, SummaryCardGroup } from './card' import { OverviewGroup, OverviewGroupSummary } from './group' diff --git a/src/pages/overview/DataElements.tsx b/src/pages/overview/DataElements.tsx index b9ee2cc6..575ee41b 100644 --- a/src/pages/overview/DataElements.tsx +++ b/src/pages/overview/DataElements.tsx @@ -1,6 +1,6 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' -import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../constants' +import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../lib' import { FilterAuthorizedSections, SummaryCard, SummaryCardGroup } from './card' import { OverviewGroup, OverviewGroupSummary } from './group' diff --git a/src/types/section.ts b/src/types/section.ts index a00d0000..2a5c95e7 100644 --- a/src/types/section.ts +++ b/src/types/section.ts @@ -1,4 +1,4 @@ -import type { OverviewSectionName } from '../constants' +import type { OverviewSectionName } from '../lib' import { SchemaAuthorities, SchemaName } from './schemaBase' export interface SectionBase { diff --git a/yarn.lock b/yarn.lock index f9181ce3..9a9247c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14793,6 +14793,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"