Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,9 @@ msgstr "No"
msgid "Not answered"
msgstr "Not answered"

msgid "No value"
msgstr "No value"

msgid ""
"{{- dataSourceTetName}} dimensions cannot be combined with {{- "
"layoutTetName}} dimensions already in the layout."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe('OptionSetCondition', () => {
createOptionsResolver()
)

await waitFor(() => expect(leftOptionNames()).toHaveLength(4))
await waitFor(() => expect(leftOptionNames()).toHaveLength(5))

selectOption('Absconded')
await waitFor(() => expect(onChange).toHaveBeenCalled())
Expand All @@ -112,7 +112,9 @@ describe('OptionSetCondition', () => {
search('ch')
await new Promise((resolve) => setTimeout(resolve, 700))

await waitFor(() => expect(leftOptionNames()).toEqual(['Discharged']))
await waitFor(() =>
expect(leftOptionNames()).toEqual(['No value', 'Discharged'])
)
})

it('keeps a previously-selected option when adding another from a filtered list', async () => {
Expand All @@ -137,7 +139,9 @@ describe('OptionSetCondition', () => {

search('disch')
await new Promise((resolve) => setTimeout(resolve, 700))
await waitFor(() => expect(leftOptionNames()).toEqual(['Discharged']))
await waitFor(() =>
expect(leftOptionNames()).toEqual(['No value', 'Discharged'])
)

selectOption('Discharged')
await waitFor(() => expect(onChange).toHaveBeenCalledWith('IN:ABS;DIS'))
Expand All @@ -148,4 +152,31 @@ describe('OptionSetCondition', () => {
* the source list and the Transfer would re-derive the missing label. */
expect(rightOptionNames()).toEqual(['Absconded', 'Discharged'])
})

it('lists the no-value option first, even when a search matches nothing', async () => {
await renderCondition(createOptionsResolver())

await waitFor(() => expect(leftOptionNames()[0]).toBe('No value'))

search('zzz')
await new Promise((resolve) => setTimeout(resolve, 700))

await waitFor(() => expect(leftOptionNames()).toEqual(['No value']))
})

it('stores the no-value option by its code and labels it in the selection', async () => {
const { onChange, rerenderWithLatestCondition } = await renderCondition(
createOptionsResolver()
)

await waitFor(() => expect(leftOptionNames()).toHaveLength(5))

selectOption('No value')
await waitFor(() =>
expect(onChange).toHaveBeenCalledWith('IN:D2__NOVALUE')
)
rerenderWithLatestCondition()

expect(rightOptionNames()).toEqual(['No value'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ import { TransferSourceEmptyPlaceholder } from '@components/dimension-modal/tran
import { useInfiniteTransferOptions } from '@components/dimension-modal/transfer/use-infinite-transfer-options'
import { Transfer, TransferOption } from '@dhis2/ui'
import { useAddMetadata, useOptionSetMetadataItem } from '@hooks'
import { OPERATOR_IN } from '@modules/conditions'
import {
NO_VALUE_OPTION_CODE,
OPERATOR_IN,
getNoValueOptionName,
} from '@modules/conditions'
import { logger } from '@modules/logger'
import { type FC, useMemo } from 'react'
import { type ComponentProps, type FC, useMemo } from 'react'
import { type FetchResult, optionsApi } from './options-api'
import classes from './styles/option-set-condition.module.css'

type OptionSetConditionProps = {
condition: string
Expand All @@ -37,9 +42,30 @@ const toSelectedOptionsLookup = (
for (const { code, name } of Object.values(optionsByCode)) {
lookup[code] = { value: code, label: name }
}
lookup[NO_VALUE_OPTION_CODE] = {
value: NO_VALUE_OPTION_CODE,
label: getNoValueOptionName(),
}
return lookup
}

const renderOption = ({
label,
...props
}: ComponentProps<typeof TransferOption>) => (
<TransferOption
{...props}
label={
props.value === NO_VALUE_OPTION_CODE ? (
<span className={classes.noValueOption}>{label}</span>
) : (
label
)
}
dataTest="option-set-transfer-option"
/>
)

export const OptionSetCondition: FC<OptionSetConditionProps> = ({
condition,
optionSetId,
Expand Down Expand Up @@ -90,6 +116,10 @@ export const OptionSetCondition: FC<OptionSetConditionProps> = ({

const optionsMetadata = selected.reduce<FetchResult['items']>(
(options, selectedId) => {
if (selectedId === NO_VALUE_OPTION_CODE) {
return options
}

const option = allOptionsByCode[selectedId]

if (option) {
Expand Down Expand Up @@ -136,7 +166,10 @@ export const OptionSetCondition: FC<OptionSetConditionProps> = ({
}

const transferOptions = useMemo(
() => data.map(({ code, name }) => ({ value: code, label: name })),
() => [
{ value: NO_VALUE_OPTION_CODE, label: getNoValueOptionName() },
...data.map(({ code, name }) => ({ value: code, label: name })),
],
[data]
)

Expand Down Expand Up @@ -170,12 +203,7 @@ export const OptionSetCondition: FC<OptionSetConditionProps> = ({
selectedWidth={TRANSFER_SELECTED_WIDTH}
selectedEmptyComponent={<TransferEmptySelection />}
rightHeader={<TransferRightHeader />}
renderOption={(props) => (
<TransferOption
{...props}
dataTest={`${dataTest}-transfer-option`}
/>
)}
renderOption={renderOption}
dataTest={`${dataTest}-transfer`}
/>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.noValueOption {
color: var(--colors-grey700);
}

:global(.highlighted) > .noValueOption {
color: inherit;
}
Comment on lines +5 to +7

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear why the global style is also needed here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HendrikThePendric It's to make sure the text is white when the item is selected (green background). Otherwise it gets a grey700 text color. Let me know if there is a better way to do it.

43 changes: 41 additions & 2 deletions src/hooks/__tests__/use-conditions-texts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ vi.mock('@modules/conditions', () => ({
getBooleanConditionTexts: mockGetBooleanConditionTexts,
getOrgUnitConditionMetadataIds: mockGetOrgUnitConditionMetadataIds,
getOperatorConditionTexts: mockGetOperatorConditionTexts,
NO_VALUE_OPTION_CODE: 'D2__NOVALUE',
getNoValueOptionName: () => 'No value',
}))

type WrapperProps = { children: ReactNode }
Expand Down Expand Up @@ -138,7 +140,7 @@ describe('useConditionsTexts metadata updates', () => {
expect(result.current.texts).toEqual(['Legend Alpha', 'Legend Beta'])
})

it('updates option set condition texts once the option set metadata becomes available', () => {
it('names option set condition texts as their metadata arrives, falling back to the code', () => {
mockShouldUseOptionSetConditions.mockReturnValue(true)
const optionSetId = 'OS_123'
const selectedOptionCodes = ['A', 'B']
Expand Down Expand Up @@ -170,7 +172,7 @@ describe('useConditionsTexts metadata updates', () => {
})
})

expect(result.current.texts).toEqual(['Alpha'])
expect(result.current.texts).toEqual(['Alpha', 'B'])

act(() => {
result.current.addMetadata({
Expand Down Expand Up @@ -325,4 +327,41 @@ describe('useConditionsTexts metadata updates', () => {

expect(result.current.texts).toEqual(['Alpha', 'Beta'])
})

it('keeps the selected order and names the no-value option without metadata', () => {
mockShouldUseOptionSetConditions.mockReturnValue(true)
const optionSetId = 'OS_ORDER'
mockGetOptionSetIdAndSelectedOptionCodes.mockReturnValue({
optionSetId,
selectedOptionCodes: ['B', 'D2__NOVALUE', 'A'],
})

const { result } = renderHook(
() => {
const texts = useConditionsTexts({
conditions: { condition: 'in:B;D2__NOVALUE;A' },
dimension: { ...baseDimension, optionSet: optionSetId },
formatValueOptions: {},
})
const addMetadata = useAddMetadata()
return { texts, addMetadata }
},
{ wrapper: DefaultWrapper }
)

expect(result.current.texts).toEqual(['B', 'No value', 'A'])

act(() => {
result.current.addMetadata({
id: optionSetId,
name: 'Status',
options: [
{ code: 'A', name: 'Alpha' },
{ code: 'B', name: 'Beta' },
],
})
})

expect(result.current.texts).toEqual(['Beta', 'No value', 'Alpha'])
})
})
29 changes: 15 additions & 14 deletions src/hooks/use-conditions-texts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
getBooleanConditionTexts,
getOrgUnitConditionMetadataIds,
getOperatorConditionTexts,
getNoValueOptionName,
NO_VALUE_OPTION_CODE,
} from '@modules/conditions'
import { isOptionSetMetadataItem } from '@modules/metadata/item-guards'
import type { SavedVisualization } from '@types'
Expand Down Expand Up @@ -92,20 +94,19 @@ export const useConditionsTexts = ({
getOptionSetIdAndSelectedOptionCodes(dimension, conditionsList)
const optionSetMetadata = metadataItems[optionSetId]

if (isOptionSetMetadataItem(optionSetMetadata)) {
const selectedOptionCodesLookup = new Set(selectedOptionCodes)
return (
optionSetMetadata.options
.filter((option) =>
selectedOptionCodesLookup.has(option.code)
)
// Prefer name
.map((option) => option.name)
)
} else {
// Fallback to ID
return selectedOptionCodes
}
const optionNamesByCode = new Map(
isOptionSetMetadataItem(optionSetMetadata)
? optionSetMetadata.options.map((option) => [
option.code,
option.name,
])
: []
)
optionNamesByCode.set(NO_VALUE_OPTION_CODE, getNoValueOptionName())

return selectedOptionCodes.map(
(code) => optionNamesByCode.get(code) ?? code
)
}
if (shouldUseBooleanConditions(conditions, dimension, conditionsList)) {
return getBooleanConditionTexts(conditionsList)
Expand Down
2 changes: 2 additions & 0 deletions src/modules/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export const FALSE_VALUE: BooleanValue = '0'
export const NULL_VALUE: BooleanValue = 'NV'
export const TRUE_VALUE: BooleanValue = '1'
export const OPERATOR_IN: QueryOperator = 'IN'
export const NO_VALUE_OPTION_CODE = 'D2__NOVALUE'
export const getNoValueOptionName = (): string => i18n.t('No value')
Comment on lines +169 to +170

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this is the correct file, but I am thinking since this is about the option set no value option , that...

  • NO_VALUE_OPTION_CODE belongs in src/constants/options.ts
  • getNoValueOptionName belongs in src/modules/options.ts

Aside: looking at this file now, I guess a lot of stuff in src/modules/conditions.ts should have maybe been in src/constants/conditions.ts, but that's not related to the current PR, so let's forget about it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I'll move it for now 👍

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though, src/modules/options.ts has visualization option stuff, so maybe it fits less there than it sounds?

export const OPERATOR_EQUAL: QueryOperator = 'EQ'
export const OPERATOR_EMPTY = `EQ:${NULL_VALUE}`
export const OPERATOR_NOT_EMPTY = `NE:${NULL_VALUE}`
Expand Down
Loading