Skip to content

Commit f2376d0

Browse files
authored
Merge pull request #912 from visualize-admin/feat/tree-sort-search
2 parents 55e62fd + 1cd14b2 commit f2376d0

10 files changed

Lines changed: 224 additions & 160 deletions

File tree

app/charts/column/columns-state.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@ import {
5555
} from "@/formatters";
5656
import { TemporalDimension, TimeUnit } from "@/graphql/query-hooks";
5757
import { getPalette } from "@/palettes";
58-
import { makeDimensionValueSorters } from "@/utils/sorting-values";
58+
import {
59+
getSortingOrders,
60+
makeDimensionValueSorters,
61+
} from "@/utils/sorting-values";
5962

6063
export interface ColumnsState extends CommonChartState {
6164
chartType: "column";
@@ -167,7 +170,14 @@ const useColumnsState = (
167170
const { xScale, yScale, xEntireScale, xScaleInteraction, bandDomain } =
168171
useMemo(() => {
169172
// x
170-
const bandDomain = [...new Set(preparedData.map(getX))];
173+
const sorters = makeDimensionValueSorters(xDimension, {
174+
sorting: fields.x.sorting,
175+
});
176+
const bandDomain = orderBy(
177+
[...new Set(preparedData.map(getX))],
178+
sorters,
179+
getSortingOrders(sorters, fields.x.sorting)
180+
);
171181
const xScale = scaleBand()
172182
.domain(bandDomain)
173183
.paddingInner(PADDING_INNER)

app/charts/shared/chart-data-filters.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
OptionGroup,
1616
Option,
1717
} from "@/configurator";
18-
import { hierarchyToOptions, TimeInput } from "@/configurator/components/field";
18+
import { TimeInput } from "@/configurator/components/field";
1919
import {
2020
getTimeIntervalFormattedSelectOptions,
2121
getTimeIntervalWithProps,
@@ -33,6 +33,7 @@ import {
3333
} from "@/graphql/query-hooks";
3434
import { Icon } from "@/icons";
3535
import { useLocale } from "@/locales/use-locale";
36+
import { hierarchyToOptions } from "@/utils/hierarchy";
3637

3738
export const ChartDataFilters = ({
3839
dataSet,

app/components/form.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { useBrowseContext } from "@/configurator/components/dataset-browse";
4343
import { Icon } from "@/icons";
4444
import { useLocale } from "@/locales/use-locale";
4545
import { MaybeTooltip } from "@/utils/maybe-tooltip";
46+
import { valueComparator } from "@/utils/sorting-values";
4647

4748
export const Label = ({
4849
htmlFor,
@@ -248,13 +249,7 @@ const getSelectOptions = (
248249
const restOptions = options.filter((o) => !o.isNoneValue);
249250

250251
if (sortOptions) {
251-
restOptions.sort((a, b) => {
252-
if (a.position !== undefined && b.position !== undefined) {
253-
return a.position < b.position;
254-
} else {
255-
return a.label.localeCompare(b.label, locale);
256-
}
257-
});
252+
restOptions.sort(valueComparator(locale));
258253
}
259254

260255
return [...noneOptions, ...restOptions];

app/components/select-tree.tsx

Lines changed: 146 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,30 @@ import MUITreeItem, {
66
TreeItemProps,
77
useTreeItem,
88
} from "@mui/lab/TreeItem";
9-
import TreeView from "@mui/lab/TreeView";
9+
import TreeView, { TreeViewProps } from "@mui/lab/TreeView";
1010
import {
1111
Theme,
12-
Menu,
12+
Popover,
1313
PopoverActions,
1414
useEventCallback,
1515
OutlinedInput,
1616
Typography,
1717
Collapse,
18+
TextField,
19+
TextFieldProps,
20+
IconButton,
1821
} from "@mui/material";
1922
import { makeStyles } from "@mui/styles";
2023
import useId from "@mui/utils/useId";
2124
import clsx from "clsx";
22-
import { useCallback, useMemo, useState } from "react";
25+
import { useCallback, useMemo, useRef, useState } from "react";
2326
import * as React from "react";
2427
import { useEffect } from "react";
2528

2629
import { Label } from "@/components/form";
30+
import { HierarchyValue } from "@/graphql/resolver-types";
2731
import { Icon } from "@/icons";
32+
import { flattenTree, pruneTree } from "@/rdf/tree-utils";
2833
import useEvent from "@/utils/use-event";
2934

3035
const useStyles = makeStyles<Theme, { disabled?: boolean; open?: boolean }>(
@@ -242,12 +247,15 @@ const TreeItem = (props: TreeItemProps) => {
242247
return <MUITreeItem {...props} ContentComponent={TreeItemContent} />;
243248
};
244249

245-
type Tree = {
246-
value: string;
247-
label: string;
248-
children?: Tree;
250+
type TreeHierachyValue = Omit<
251+
HierarchyValue,
252+
"depth" | "dimensionIri" | "children"
253+
> & {
249254
selectable?: boolean;
250-
}[];
255+
children?: TreeHierachyValue[];
256+
};
257+
258+
type Tree = TreeHierachyValue[];
251259

252260
type NodeId = string;
253261

@@ -264,6 +272,16 @@ export type SelectTreeProps = {
264272
open?: boolean;
265273
};
266274

275+
const getFilteredOptions = (options: Tree, value: string) => {
276+
const rx = new RegExp(`^${value}|\\s${value}`, "i");
277+
return value === ""
278+
? options
279+
: (pruneTree(
280+
options as HierarchyValue[],
281+
(d) => !!d.label.match(rx)
282+
) as Tree);
283+
};
284+
267285
function SelectTree({
268286
label,
269287
options,
@@ -278,20 +296,29 @@ function SelectTree({
278296
}: SelectTreeProps) {
279297
const [openState, setOpenState] = useState(false);
280298
const [minMenuWidth, setMinMenuWidth] = useState<number>();
281-
282-
const handleClick = useEventCallback((ev: React.MouseEvent<HTMLElement>) => {
283-
setOpenState(true);
284-
setMinMenuWidth(ev.currentTarget.clientWidth);
285-
onOpen?.();
286-
});
287-
288-
const handleClose = useEventCallback(() => {
289-
setOpenState(false);
290-
onClose?.();
291-
});
299+
const [inputValue, setInputValue] = useState("");
292300
const classes = useStyles({ disabled, open });
301+
const [filteredOptions, setFilteredOptions] = useState(options);
293302

294303
const parentsRef = React.useRef({} as Record<NodeId, NodeId>);
304+
const menuRef = React.useRef<PopoverActions>(null);
305+
const id = useId();
306+
const inputRef = React.useRef<HTMLDivElement>();
307+
308+
const defaultExpanded = useMemo(() => {
309+
if (!value && options.length > 0) {
310+
return options[0].value ? [options[0].value] : [];
311+
}
312+
const res = value ? [value] : [];
313+
let cur = value;
314+
const parents = parentsRef.current;
315+
while (cur && parents[cur]) {
316+
res.push(parents[cur]);
317+
cur = parents[cur];
318+
}
319+
return res;
320+
}, [value, options]);
321+
const [expanded, setExpanded] = useState(defaultExpanded);
295322

296323
const labelsByValue = useMemo(() => {
297324
parentsRef.current = {} as Record<NodeId, NodeId>;
@@ -311,20 +338,50 @@ function SelectTree({
311338
return res;
312339
}, [options]);
313340

314-
const defaultExpanded = useMemo(() => {
315-
if (!value && options.length > 0) {
316-
return options[0].value ? [options[0].value] : [];
317-
}
318-
const res = value ? [value] : [];
319-
let cur = value;
320-
const parents = parentsRef.current;
321-
while (cur && parents[cur]) {
322-
res.push(parents[cur]);
323-
cur = parents[cur];
324-
}
341+
const handleOpen = useEventCallback(() => {
342+
setOpenState(true);
343+
setMinMenuWidth(inputRef.current?.clientLeft);
344+
onOpen?.();
345+
});
325346

326-
return res;
327-
}, [value, options]);
347+
const handleClose = useEventCallback(() => {
348+
setOpenState(false);
349+
setInputValue("");
350+
setFilteredOptions(getFilteredOptions(options, ""));
351+
setExpanded(defaultExpanded);
352+
onClose?.();
353+
});
354+
355+
const handleInputChange: TextFieldProps["onChange"] = useEvent((ev) => {
356+
const value = ev.currentTarget.value;
357+
setInputValue(value);
358+
const filteredOptions = getFilteredOptions(options, value);
359+
setFilteredOptions(filteredOptions);
360+
setExpanded((curExpanded) => {
361+
const newExpanded = Array.from(
362+
new Set([
363+
...curExpanded,
364+
...flattenTree(filteredOptions as HierarchyValue[]).map(
365+
(v) => v.value
366+
),
367+
])
368+
);
369+
return newExpanded;
370+
});
371+
});
372+
373+
const handleClickResetInput = useEvent(() => {
374+
const newValue = "";
375+
setInputValue(newValue);
376+
setFilteredOptions(getFilteredOptions(options, newValue));
377+
setExpanded(defaultExpanded);
378+
});
379+
380+
const handleNodeToggle: TreeViewProps["onNodeToggle"] = useEvent(
381+
(_ev, nodeIds) => {
382+
setExpanded(nodeIds);
383+
}
384+
);
328385

329386
const handleNodeSelect = useEventCallback((_ev, value: NodeId) => {
330387
onChange({ target: { value: value } });
@@ -377,15 +434,14 @@ function SelectTree({
377434
[defaultExpanded, treeItemClasses, treeItemTransitionProps]
378435
);
379436

380-
const menuRef = React.useRef<PopoverActions>(null);
381-
382-
const paperProps = useMemo(() => {
383-
return {
437+
const paperProps = useMemo(
438+
() => ({
384439
style: {
385-
minWidth: minMenuWidth === undefined ? 0 : minMenuWidth,
440+
minWidth: minMenuWidth ?? 0,
386441
},
387-
};
388-
}, [minMenuWidth]);
442+
}),
443+
[minMenuWidth]
444+
);
389445

390446
const menuTransitionProps = useMemo(
391447
() => ({
@@ -403,8 +459,14 @@ function SelectTree({
403459
[]
404460
);
405461

406-
const id = useId();
407-
const inputRef = React.useRef<HTMLDivElement>();
462+
const treeRef = useRef();
463+
const handleKeyDown: React.HTMLAttributes<HTMLInputElement>["onKeyDown"] =
464+
useEvent((ev) => {
465+
if (ev.key === "Enter" || ev.key == " ") {
466+
handleOpen();
467+
ev.preventDefault();
468+
}
469+
});
408470

409471
useEffect(() => {
410472
const inputNode = inputRef.current;
@@ -428,10 +490,11 @@ function SelectTree({
428490
ref={inputRef}
429491
size="small"
430492
className={classes.input}
431-
onClick={disabled ? undefined : handleClick}
493+
onClick={disabled ? undefined : handleOpen}
494+
onKeyDown={handleKeyDown}
432495
endAdornment={<Icon className={classes.icon} name="caretDown" />}
433496
/>
434-
<Menu
497+
<Popover
435498
anchorOrigin={{
436499
vertical: "bottom",
437500
horizontal: "left",
@@ -443,17 +506,46 @@ function SelectTree({
443506
PaperProps={paperProps}
444507
TransitionProps={menuTransitionProps}
445508
>
446-
<TreeView
447-
defaultSelected={value}
448-
defaultExpanded={defaultExpanded}
449-
onNodeSelect={handleNodeSelect}
450-
defaultCollapseIcon={<ExpandMoreIcon />}
451-
defaultExpandIcon={<ChevronRightIcon />}
452-
sx={{ flexGrow: 1, overflowY: "auto", pb: 2, "user-select": "none" }}
453-
>
454-
{renderTreeContent(options)}
455-
</TreeView>
456-
</Menu>
509+
<TextField
510+
size="small"
511+
value={inputValue}
512+
sx={{ p: 1, width: "100%" }}
513+
InputProps={{
514+
autoFocus: true,
515+
startAdornment: <Icon name="search" size={16} color="#555" />,
516+
endAdornment: (
517+
<IconButton size="small" onClick={handleClickResetInput}>
518+
<Icon name="close" size={16} />
519+
</IconButton>
520+
),
521+
sx: { "& input": { fontSize: "12px" }, pl: 1, pr: 1 },
522+
}}
523+
onChange={handleInputChange}
524+
/>
525+
{filteredOptions.length === 0 ? (
526+
<Typography variant="body2" sx={{ px: 2, py: 4 }}>
527+
<Trans id="No results" />
528+
</Typography>
529+
) : (
530+
<TreeView
531+
ref={treeRef}
532+
defaultSelected={value}
533+
expanded={expanded}
534+
onNodeToggle={handleNodeToggle}
535+
onNodeSelect={handleNodeSelect}
536+
defaultCollapseIcon={<ExpandMoreIcon />}
537+
defaultExpandIcon={<ChevronRightIcon />}
538+
sx={{
539+
flexGrow: 1,
540+
overflowY: "auto",
541+
pb: 2,
542+
"user-select": "none",
543+
}}
544+
>
545+
{renderTreeContent(filteredOptions)}
546+
</TreeView>
547+
)}
548+
</Popover>
457549
</div>
458550
);
459551
}

0 commit comments

Comments
 (0)