From cf7d01f877a9fa61107e25071211f0207f69b8ef Mon Sep 17 00:00:00 2001 From: Birk Johansson Date: Wed, 20 Nov 2024 18:37:53 +0100 Subject: [PATCH] fix(categoryCombo): add validation and errors for categorycombo --- .../ModelTransfer/BaseModelTransfer.tsx | 1 + src/lib/form/modelFormSchemas.ts | 1 + .../categoryCombos/form/CategoriesField.tsx | 153 ++++++++++++++++++ .../form/CategoryCombo.module.css | 21 +++ .../form/CategoryComboFormFields.tsx | 21 +-- .../form/CategoryComboWarnings.tsx | 0 .../form/categoryComboSchema.ts | 31 +++- .../form/useIdenticalCategoryCombosQuery.tsx | 51 ++++++ src/types/query.ts | 2 +- 9 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 src/pages/categoryCombos/form/CategoriesField.tsx create mode 100644 src/pages/categoryCombos/form/CategoryCombo.module.css create mode 100644 src/pages/categoryCombos/form/CategoryComboWarnings.tsx create mode 100644 src/pages/categoryCombos/form/useIdenticalCategoryCombosQuery.tsx diff --git a/src/components/metadataFormControls/ModelTransfer/BaseModelTransfer.tsx b/src/components/metadataFormControls/ModelTransfer/BaseModelTransfer.tsx index 22d37caf..4434b481 100644 --- a/src/components/metadataFormControls/ModelTransfer/BaseModelTransfer.tsx +++ b/src/components/metadataFormControls/ModelTransfer/BaseModelTransfer.tsx @@ -58,6 +58,7 @@ export const BaseModelTransfer = ({ return ( ['categories'] + +const query = { + resource: 'categories', + params: { + fields: [ + 'id', + 'displayName', + 'categoryOptions~size~rename(categoryOptionsSize)', + ], + order: 'displayName:asc', + filter: ['name:ne:default'], + }, +} + +const fieldName = 'categories' +const numberFormat = new Intl.NumberFormat() + +export function CategoriesField() { + const catComboId = useParams()?.id + + const { input, meta } = useField< + Pick['categories'] + >(fieldName, { + multiple: true, + validateFields: [], + }) + + const generatedCocsCount = + input.value?.reduce((acc, cat) => acc * cat.categoryOptionsSize, 1) || 0 + return ( +
+ + { + input.onChange(selected) + input.onBlur() + }} + leftHeader={i18n.t('Available categories')} + rightHeader={i18n.t('Selected categories')} + filterPlaceholder={i18n.t('Filter available categories')} + filterPlaceholderPicked={i18n.t( + 'Filter selected categories' + )} + query={query} + maxSelections={200} + /> + +
+ {!catComboId && generatedCocsCount > 1 && ( + 250}> + {i18n.t( + '{{count}} category option combinations will be generated.', + { + count: numberFormat.format(generatedCocsCount), + } + )} + + )} + +
+
+ ) +} + +const CategoriesFieldWarnings = ({ + selectedCategories, + catComboId, +}: { + selectedCategories: CategoriesValue + catComboId?: string +}) => { + const isMoreThanRecommended = selectedCategories.length > 4 + const identicalCatCombosResult = useIdenticalCategoryCombosQuery({ + categoryComboId: catComboId, + selectedCategories, + enabled: !isMoreThanRecommended, + }) + const isIdenticalCatCombos = + identicalCatCombosResult.isSuccess && + identicalCatCombosResult.data?.pager.total > 0 + + if (!isMoreThanRecommended && !isIdenticalCatCombos) { + return null + } + + return ( + <> + {isIdenticalCatCombos && ( + + )} + {isMoreThanRecommended && ( + + {i18n.t( + 'A Category combination with more than 4 categories is not recommended.' + )} + + )} + + ) +} + +const IdenticalCategoryComboWarning = ({ + categoryCombos, +}: { + categoryCombos: DisplayableModel[] +}) => { + return ( + + {i18n.t( + `One or more Category combinations with the same categories already exist in the system. + It is strongly discouraged to have more than one Category combination with the same categories.` + )} +
+ {i18n.t( + 'The following Category combinations have identical categories:' + )} +
    + {categoryCombos.map((catCombo) => ( +
  • + + {catCombo.displayName} + +
  • + ))} +
+
+ ) +} diff --git a/src/pages/categoryCombos/form/CategoryCombo.module.css b/src/pages/categoryCombos/form/CategoryCombo.module.css new file mode 100644 index 00000000..cec4ea36 --- /dev/null +++ b/src/pages/categoryCombos/form/CategoryCombo.module.css @@ -0,0 +1,21 @@ +.categoriesFieldWrapper { + display: flex; + flex-wrap: wrap; + gap: var(--spacers-dp8); +} + +.categoriesNoticesWrapper { + display: flex; + flex-direction: column; + gap: var(--spacers-dp8); + max-width: 570px; +} + +.identicalCategoriesList a { + color: var(--colors-blue600); +} + +.identicalCategoriesList { + margin-inline: 0; + margin-block: var(--spacers-dp8); +} diff --git a/src/pages/categoryCombos/form/CategoryComboFormFields.tsx b/src/pages/categoryCombos/form/CategoryComboFormFields.tsx index 11f2004b..6b28b6f7 100644 --- a/src/pages/categoryCombos/form/CategoryComboFormFields.tsx +++ b/src/pages/categoryCombos/form/CategoryComboFormFields.tsx @@ -13,6 +13,7 @@ import { CodeField, } from '../../../components' import { SECTIONS_MAP } from '../../../lib' +import { CategoriesField } from './CategoriesField' const section = SECTIONS_MAP.categoryCombo @@ -84,25 +85,7 @@ export const CategoryComboFormFields = () => { )} - - - + diff --git a/src/pages/categoryCombos/form/CategoryComboWarnings.tsx b/src/pages/categoryCombos/form/CategoryComboWarnings.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/pages/categoryCombos/form/categoryComboSchema.ts b/src/pages/categoryCombos/form/categoryComboSchema.ts index 187eef22..aebfd8e4 100644 --- a/src/pages/categoryCombos/form/categoryComboSchema.ts +++ b/src/pages/categoryCombos/form/categoryComboSchema.ts @@ -4,8 +4,9 @@ import { getDefaults, modelFormSchemas } from '../../../lib' import { createFormValidate } from '../../../lib/form/validate' import { CategoryCombo } from './../../../types/generated/models' -const { identifiable, withAttributeValues, referenceCollection } = - modelFormSchemas +const { identifiable, withAttributeValues, modelReference } = modelFormSchemas + +const GENERATED_COC_LIMIT = 50000 export const categoryComboSchema = identifiable .merge(withAttributeValues) @@ -15,11 +16,33 @@ export const categoryComboSchema = identifiable .nativeEnum(CategoryCombo.dataDimensionType) .default(CategoryCombo.dataDimensionType.DISAGGREGATION), skipTotal: z.boolean().default(false), - categories: referenceCollection - .min(1, i18n.t('At least one category is required')) + categories: z + .array( + modelReference.extend({ + displayName: z.string(), + categoryOptionsSize: z.number(), + }) + ) + .refine( + (categories) => { + const generatedCocsCount = categories.reduce( + (acc, category) => acc * category.categoryOptionsSize, + 1 + ) + return generatedCocsCount < GENERATED_COC_LIMIT + }, + { + message: i18n.t( + 'The number of generated category option combinations exceeds the limit of {{limit}}', + { limit: GENERATED_COC_LIMIT } + ), + } + ) .default([]), }) export const initialValues = getDefaults(categoryComboSchema) +export type CategoryComboFormValues = typeof initialValues + export const validate = createFormValidate(categoryComboSchema) diff --git a/src/pages/categoryCombos/form/useIdenticalCategoryCombosQuery.tsx b/src/pages/categoryCombos/form/useIdenticalCategoryCombosQuery.tsx new file mode 100644 index 00000000..9513c1c3 --- /dev/null +++ b/src/pages/categoryCombos/form/useIdenticalCategoryCombosQuery.tsx @@ -0,0 +1,51 @@ +import { useQuery, QueryObserverOptions } from 'react-query' +import { useBoundResourceQueryFn } from '../../../lib/query/useBoundQueryFn' +import { PagedResponse } from '../../../types/generated' +import { CategoryComboFormValues } from './categoryComboSchema' + +type CategoriesValue = Pick['categories'] + +export type IdenticalCategoryCombosQueryResult = PagedResponse< + CategoriesValue[number], + 'categoryCombos' +> + +type UseIdenticalCategoryCombosQuery = { + categoryComboId?: string + selectedCategories: CategoriesValue +} & Pick< + QueryObserverOptions, + 'enabled' | 'select' | 'cacheTime' | 'staleTime' +> + +export const useIdenticalCategoryCombosQuery = ({ + categoryComboId, + selectedCategories, + ...queryOptions +}: UseIdenticalCategoryCombosQuery) => { + const queryFn = useBoundResourceQueryFn() + const notSameCatComboFilter = `id:ne:${categoryComboId}` + const idFilters = selectedCategories.map( + (category) => `categories.id:eq:${category.id}` + ) + const lengthFilter = `categories:eq:${selectedCategories.length}` + const filters = [lengthFilter, ...idFilters] + + if (categoryComboId) { + filters.push(notSameCatComboFilter) + } + const useIdenticalCategoryCombosQuery = { + resource: 'categoryCombos', + params: { + fields: ['id', 'displayName'], + filter: filters, + }, + } + + return useQuery({ + staleTime: 60 * 1000, + ...queryOptions, + queryKey: [useIdenticalCategoryCombosQuery], + queryFn: queryFn, + }) +} diff --git a/src/types/query.ts b/src/types/query.ts index 4cf166b4..26887911 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -10,7 +10,7 @@ type QueryParams = { pageSize?: number page?: number fields?: string | string[] - filter: string | string[] + filter?: string | string[] [key: string]: unknown }