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
210 changes: 156 additions & 54 deletions src/components/Collection/Filter/MultiValueCriteriaModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react';
import type { ChangeEvent } from 'react';
import { filter, map, pull } from 'lodash';
import { filter, map } from 'lodash';

import Button from '@/components/Input/Button';
import Input from '@/components/Input/Input';
import Select from '@/components/Input/Select';
import ModalPanel from '@/components/Panels/ModalPanel';
import { updateLeafValue } from '@/core/slices/collection';
Expand All @@ -18,83 +19,141 @@ type Props = {
show: boolean;
};

// Pair values are joined into a single display string only for this picker's own list -
// matched back against the catalog's original pair rather than re-split, so a value that
// happens to contain ': ' can't be misinterpreted. The dispatched LeafValue always carries
// real [string, string] tuples, never the joined string as the source of truth.
const displayPair = (pair: string[]) => pair.join(': ');
// Each value is kept as its raw parameters ([first] or [first, second]) and joined into a
// single display string only for this picker's own lists and keys - never re-split, so a
// value that happens to contain ': ' can't be misinterpreted.
const displayEntry = (entry: string[]) => entry.join(': ');

type ParameterInputProps = {
id: string;
label: string;
onChange: (value: string) => void;
onSubmit: () => void;
possibleValues?: string[];
type?: string;
value: string;
};

// A listed parameter is always picked from the server's list; free text is only offered
// when the server sends no list for that parameter.
const ParameterInput = ({ id, label, onChange, onSubmit, possibleValues, type, value }: ParameterInputProps) => {
if (possibleValues) {
return (
<Select id={id} label={label} value={value} onChange={event => onChange(event.target.value)}>
<option value="" disabled>{`--Select ${label}--`}</option>
{map(possibleValues, item => <option key={item} value={item}>{item}</option>)}
</Select>
);
}
return (
<Input
id={id}
label={label}
type={type === 'Number' ? 'number' : 'text'}
value={value}
onChange={event => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onSubmit();
}}
/>
);
};

const MultiValueCriteriaModal = ({ catalogEntry, node, onClose, onRemove, show }: Props) => {
const dispatch = useDispatch();
const isPair = node.value.kind === 'multiPair';

const selectedDisplayValues = useMemo(() => {
if (node.value.kind === 'multiPair') return node.value.values.map(displayPair);
if (node.value.kind === 'multi') return node.value.values;
const selectedEntries = useMemo(() => {
if (node.value.kind === 'multiPair') return node.value.values;
if (node.value.kind === 'multi') return node.value.values.map(value => [value]);
return [];
}, [node.value]);
const initialMatch = node.value.kind === 'multi' || node.value.kind === 'multiPair' ? node.value.match : 'Or';

const [unsavedValues, setUnsavedValues] = useState<string[]>([]);
const [unsavedEntries, setUnsavedEntries] = useState<string[][]>([]);
const [match, setMatch] = useState<'And' | 'Or'>(initialMatch);
const [firstValue, setFirstValue] = useState('');
const [secondValue, setSecondValue] = useState('');

const usedDisplayValues = useMemo(
() => [...selectedEntries, ...unsavedEntries].map(displayEntry),
[selectedEntries, unsavedEntries],
);

// The server sends either pairs or separate per-parameter lists, never both. Pairs, or a
// single listed parameter, are picked from one list; anything else gets one input per
// parameter.
const possibleEntries = useMemo(
() => (isPair ? catalogEntry.PossibleParameterPairs : catalogEntry.PossibleParameters?.map(value => [value])),
[catalogEntry.PossibleParameterPairs, catalogEntry.PossibleParameters, isPair],
);

const unusedValues = useMemo(
() => {
const possibleValues = catalogEntry.PossibleParameters
?? catalogEntry.PossibleParameterPairs?.map(displayPair);

return filter(
possibleValues,
item => !selectedDisplayValues.includes(item) && !unsavedValues.includes(item),
);
},
[catalogEntry.PossibleParameters, catalogEntry.PossibleParameterPairs, selectedDisplayValues, unsavedValues],
const unusedEntries = useMemo(
() => filter(possibleEntries, entry => !usedDisplayValues.includes(displayEntry(entry))),
[possibleEntries, usedDisplayValues],
);

const buildValue = (displayValues: string[], matchValue: 'And' | 'Or'): LeafValue => {
const firstLabel = catalogEntry.ParameterName ?? 'Value';
const secondLabel = catalogEntry.SecondParameterName ?? 'Second Value';
let listLabel: string | undefined;
if (isPair && (catalogEntry.ParameterName ?? catalogEntry.SecondParameterName)) {
listLabel = displayEntry([firstLabel, secondLabel]);
} else if (!isPair) {
listLabel = catalogEntry.ParameterName;
}

const buildValue = (entries: string[][], matchValue: 'And' | 'Or'): LeafValue => {
if (isPair) {
return {
kind: 'multiPair',
match: matchValue,
values: displayValues.map((display): [string, string] => {
const pair = catalogEntry.PossibleParameterPairs?.find(candidate => displayPair(candidate) === display);
return pair ? [pair[0], pair[1]] : [display, ''];
}),
values: entries.map((entry): [string, string] => [entry[0], entry[1] ?? '']),
};
}
return { kind: 'multi', values: displayValues, match: matchValue };
return { kind: 'multi', values: entries.map(entry => entry[0]), match: matchValue };
};

const handleMatchChange = (event: ChangeEvent<HTMLSelectElement>) => setMatch(event.target.value as 'And' | 'Or');

const selectValue = (value: string) => {
setUnsavedValues([...unsavedValues, value]);
const selectEntry = (entry: string[]) => {
setUnsavedEntries([...unsavedEntries, entry]);
};

const enteredEntry = isPair ? [firstValue.trim(), secondValue.trim()] : [firstValue.trim()];
const canAddEntered = enteredEntry.every(value => value !== '')
&& !usedDisplayValues.includes(displayEntry(enteredEntry));

const addEnteredEntry = () => {
if (!canAddEntered) return;
selectEntry(enteredEntry);
setFirstValue('');
setSecondValue('');
};

const removeValue = (value: string) => {
if (unsavedValues.includes(value)) {
setUnsavedValues(pull([...unsavedValues], value));
const removeEntry = (display: string) => {
if (unsavedEntries.some(entry => displayEntry(entry) === display)) {
setUnsavedEntries(unsavedEntries.filter(entry => displayEntry(entry) !== display));
}
if (selectedDisplayValues.includes(value)) {
if (selectedEntries.some(entry => displayEntry(entry) === display)) {
dispatch(updateLeafValue({
nodeId: node.id,
value: buildValue(pull([...selectedDisplayValues], value), match),
value: buildValue(selectedEntries.filter(entry => displayEntry(entry) !== display), match),
}));
}
};

const handleCancel = () => {
setUnsavedValues([]);
if (selectedDisplayValues.length === 0) onRemove();
setUnsavedEntries([]);
if (selectedEntries.length === 0) onRemove();
onClose();
};

const handleSave = () => {
dispatch(updateLeafValue({
nodeId: node.id,
value: buildValue([...selectedDisplayValues, ...unsavedValues], match),
value: buildValue([...selectedEntries, ...unsavedEntries], match),
}));
setUnsavedValues([]);
setUnsavedEntries([]);
onClose();
};

Expand All @@ -111,29 +170,72 @@ const MultiValueCriteriaModal = ({ catalogEntry, node, onClose, onRemove, show }
<option value="Or">Match Any</option>
<option value="And">Match All</option>
</Select>
<div className="flex grow basis-0 overflow-y-auto rounded-lg bg-panel-input p-4">
<div className="flex w-full flex-col gap-y-2 overflow-y-auto bg-panel-input">
{map(unusedValues, value => (
<div
onClick={() => {
selectValue(value);
}}
key={value}
className="cursor-pointer capitalize"
>
{value}
{possibleEntries
? (
<div className="flex grow flex-col gap-y-4">
{listLabel && <div className="font-semibold">{listLabel}</div>}
<div className="flex grow basis-0 overflow-y-auto rounded-lg bg-panel-input p-4">
<div className="flex w-full flex-col gap-y-2 overflow-y-auto bg-panel-input">
{map(unusedEntries, (entry) => {
const display = displayEntry(entry);
return (
<div
onClick={() => {
selectEntry(entry);
}}
key={display}
className="cursor-pointer capitalize"
>
{display}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
)
: (
<div className="flex flex-col gap-y-4">
<ParameterInput
id="parameter"
label={firstLabel}
onChange={setFirstValue}
onSubmit={addEnteredEntry}
possibleValues={catalogEntry.PossibleParameters}
type={catalogEntry.Parameter}
value={firstValue}
/>
{isPair && (
<ParameterInput
id="secondParameter"
label={secondLabel}
onChange={setSecondValue}
onSubmit={addEnteredEntry}
possibleValues={catalogEntry.PossibleSecondParameters}
type={catalogEntry.SecondParameter}
value={secondValue}
/>
)}
<div className="flex justify-end font-semibold">
<Button
onClick={addEnteredEntry}
buttonType="primary"
className="px-6 py-2"
disabled={!canAddEntered}
>
Add
</Button>
</div>
</div>
)}
<div className="flex grow flex-col gap-y-4">
<div className="font-semibold">Selected Values</div>
<div className="flex grow basis-0 overflow-y-auto rounded-lg bg-panel-input p-4">
<div className="flex w-full flex-col gap-y-2 overflow-y-auto bg-panel-input">
{map([...selectedDisplayValues, ...unsavedValues], value => (
{map(usedDisplayValues, value => (
<div
onClick={() => {
removeValue(value);
removeEntry(value);
}}
key={value}
className="cursor-pointer capitalize"
Expand Down
2 changes: 2 additions & 0 deletions src/core/types/api/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type FilterExpression = {
Right?: string;
Parameter?: string;
SecondParameter?: string;
ParameterName?: string;
SecondParameterName?: string;
PossibleParameters?: string[];
PossibleSecondParameters?: string[];
PossibleParameterPairs?: string[][];
Expand Down
7 changes: 4 additions & 3 deletions src/core/utilities/filterTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ export const generateNodeId = (): string => {
const TAG_LIKE_EXPRESSIONS = new Set(['HasTag', 'HasCustomTag']);

// Single source of truth for widget selection, replacing the two independent hardcoded
// heuristics that used to live in Criteria.tsx and buildSidebarFilterCondition.
// heuristics that used to live in Criteria.tsx and buildSidebarFilterCondition. Any
// expression taking parameters gets a value widget, whether or not the server lists values.
export const getWidgetKind = (entry: FilterExpression): 'boolean' | 'multi' | 'multiPair' | 'tag' => {
if (TAG_LIKE_EXPRESSIONS.has(entry.Expression)) return 'tag';
if (entry.PossibleParameterPairs) return 'multiPair';
if (entry.PossibleParameters ?? entry.Parameter === 'Number') return 'multi';
if (entry.PossibleParameterPairs ?? entry.SecondParameter) return 'multiPair';
if (entry.PossibleParameters ?? entry.Parameter) return 'multi';
return 'boolean';
};

Expand Down
18 changes: 16 additions & 2 deletions tests/core/utilities/filterTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,25 @@ describe('getWidgetKind', () => {
expect(getWidgetKind(PAIR)).toBe('multiPair');
});

it('classifies multi-capable expressions as multi (via PossibleParameters or a Number Parameter)', () => {
it('classifies multi-capable expressions as multi (via PossibleParameters or any Parameter)', () => {
expect(getWidgetKind(MULTI)).toBe('multi');
expect(getWidgetKind(MULTI_NUMBER)).toBe('multi');
// Parameter: 'Number' alone (no PossibleParameters) also selects the multi widget.
// A Parameter without PossibleParameters (free text) also selects the multi widget.
expect(getWidgetKind(makeEntry('InYear', { Parameter: 'Number' }))).toBe('multi');
expect(getWidgetKind(makeEntry('HasCharacter', { Parameter: 'String' }))).toBe('multi');
});

it('classifies two-parameter expressions without pairs as multiPair', () => {
expect(
getWidgetKind(
makeEntry('HasCharacterWithAppearance', {
Parameter: 'String',
PossibleSecondParameters: ['MainCharacter', 'Cameo'],
SecondParameter: 'String',
}),
),
)
.toBe('multiPair');
});

it('classifies plain expressions as boolean', () => {
Expand Down
Loading