Skip to content

Commit

Permalink
fix(categoryCombo): add validation and errors for categorycombo
Browse files Browse the repository at this point in the history
  • Loading branch information
Birkbjo committed Nov 20, 2024
1 parent e6b5d48 commit cf7d01f
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const BaseModelTransfer = <TModel extends DisplayableModel>({

return (
<Transfer
maxSelections={5000}
{...transferProps}
selected={selectedTransferValues}
options={allTransferOptions}
Expand Down
1 change: 1 addition & 0 deletions src/lib/form/modelFormSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ export const modelFormSchemas = {
identifiable,
attributeValues,
withAttributeValues,
modelReference,
}
153 changes: 153 additions & 0 deletions src/pages/categoryCombos/form/CategoriesField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import i18n from '@dhis2/d2-i18n'
import { Field, NoticeBox } from '@dhis2/ui'
import React from 'react'
import { useField } from 'react-final-form'
import { NavLink, useParams } from 'react-router-dom'
import { ModelTransfer } from '../../../components'
import { DisplayableModel } from '../../../types/models'
import css from './CategoryCombo.module.css'
import { CategoryComboFormValues } from './categoryComboSchema'
import { useIdenticalCategoryCombosQuery } from './useIdenticalCategoryCombosQuery'

type CategoriesValue = Pick<CategoryComboFormValues, 'categories'>['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<CategoryComboFormValues, 'categories'>['categories']
>(fieldName, {
multiple: true,
validateFields: [],
})

const generatedCocsCount =
input.value?.reduce((acc, cat) => acc * cat.categoryOptionsSize, 1) || 0
return (
<div className={css.categoriesFieldWrapper}>
<Field
dataTest="formfields-modeltransfer"
error={meta.invalid}
validationText={(meta.touched && meta.error?.toString()) || ''}
name={fieldName}
>
<ModelTransfer
selected={input.value}
onChange={({ selected }) => {
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}
/>
</Field>
<div className={css.categoriesNoticesWrapper}>
{!catComboId && generatedCocsCount > 1 && (
<NoticeBox warning={generatedCocsCount > 250}>
{i18n.t(
'{{count}} category option combinations will be generated.',
{
count: numberFormat.format(generatedCocsCount),
}
)}
</NoticeBox>
)}
<CategoriesFieldWarnings
catComboId={catComboId}
selectedCategories={input.value}
/>
</div>
</div>
)
}

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 && (
<IdenticalCategoryComboWarning
categoryCombos={
identicalCatCombosResult.data.categoryCombos
}
/>
)}
{isMoreThanRecommended && (
<NoticeBox warning title={i18n.t('More than 4 Categories')}>
{i18n.t(
'A Category combination with more than 4 categories is not recommended.'
)}
</NoticeBox>
)}
</>
)
}

const IdenticalCategoryComboWarning = ({
categoryCombos,
}: {
categoryCombos: DisplayableModel[]
}) => {
return (
<NoticeBox warning title={i18n.t('Identical Category Combination')}>
{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.`
)}
<br />
{i18n.t(
'The following Category combinations have identical categories:'
)}
<ul className={css.identicalCategoriesList}>
{categoryCombos.map((catCombo) => (
<li key={catCombo.id}>
<NavLink target="_blank" to={`../${catCombo.id}`}>
{catCombo.displayName}
</NavLink>
</li>
))}
</ul>
</NoticeBox>
)
}
21 changes: 21 additions & 0 deletions src/pages/categoryCombos/form/CategoryCombo.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
21 changes: 2 additions & 19 deletions src/pages/categoryCombos/form/CategoryComboFormFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CodeField,
} from '../../../components'
import { SECTIONS_MAP } from '../../../lib'
import { CategoriesField } from './CategoriesField'

const section = SECTIONS_MAP.categoryCombo

Expand Down Expand Up @@ -84,25 +85,7 @@ export const CategoryComboFormFields = () => {
)}
</StandardFormSectionDescription>
<StandardFormField>
<StandardFormField>
<ModelTransferField
name="categories"
query={{
resource: 'categories',
params: {
filter: ['name:ne:default'],
},
}}
leftHeader={i18n.t('Available categories')}
rightHeader={i18n.t('Selected categories')}
filterPlaceholder={i18n.t(
'Filter available categories'
)}
filterPlaceholderPicked={i18n.t(
'Filter selected categories'
)}
/>
</StandardFormField>
<CategoriesField />
</StandardFormField>
</StandardFormSection>
</>
Expand Down
Empty file.
31 changes: 27 additions & 4 deletions src/pages/categoryCombos/form/categoryComboSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
51 changes: 51 additions & 0 deletions src/pages/categoryCombos/form/useIdenticalCategoryCombosQuery.tsx
Original file line number Diff line number Diff line change
@@ -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<CategoryComboFormValues, 'categories'>['categories']

export type IdenticalCategoryCombosQueryResult = PagedResponse<
CategoriesValue[number],
'categoryCombos'
>

type UseIdenticalCategoryCombosQuery = {
categoryComboId?: string
selectedCategories: CategoriesValue
} & Pick<
QueryObserverOptions<IdenticalCategoryCombosQueryResult>,
'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<IdenticalCategoryCombosQueryResult>,
})
}
2 changes: 1 addition & 1 deletion src/types/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type QueryParams = {
pageSize?: number
page?: number
fields?: string | string[]
filter: string | string[]
filter?: string | string[]
[key: string]: unknown
}

Expand Down

0 comments on commit cf7d01f

Please sign in to comment.