CEC-4564: add trie select component (#404)

* add TrieSelect

* setup menu button

* CEC-4564: add trie select component

* CEC-4564: fix selectall bool check

* update tests
This commit is contained in:
Tristan Timblin
2023-07-31 11:08:23 -04:00
committed by GitHub
parent 56dd4a0c8f
commit 5716832a81
10 changed files with 1460 additions and 85 deletions

View File

@@ -0,0 +1,205 @@
import React, { useState } from "react";
import {
Box,
Button,
Checkbox,
Chip,
Collapse,
FormControlLabel,
List,
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
} from "@material-ui/core";
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import { Trie } from "./trie";
import { TrieSelectProvider, useTrieSelect } from "./TrieSelectContext";
import useStyles from "../../useStyles";
export const TrieSelect = ({
label,
classification,
onChange = () => { },
options = [],
}) => {
return (
<TrieSelectProvider onChange={onChange}>
<TrieSelectList
label={label}
classification={classification}
options={options}
/>
</TrieSelectProvider>
);
}
const TrieSelectList = ({
label,
classification,
options,
}) => {
const { selected, setSelected, remove } = useTrieSelect();
const [open, setOpen] = useState(false);
const classes = useStyles();
const trie = new Trie(options);
const handleExpand = () => {
setOpen(open => !open);
};
const handleSelectAll = () => {
setSelected((selected) => {
if (selected.length === 0) {
return options;
}
return [];
})
}
return (
<>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Button onClick={handleExpand} variant="contained" color="primary" disabled={options.length === 0}>
{label}
</Button>
<FormControlLabel
label={`Select All ${options.length}`}
labelPlacement="start"
control={
<Checkbox
checked={selected.length === options.length}
onClick={handleSelectAll}
/>
}
/>
</Box>
<Collapse in={open}>
<List>
<TrieSelectLevel
node={trie.getRoot()}
classification={classification}
/>
</List>
</Collapse>
<ul className={classes.chipList}>
{selected.map((signal) => {
return (
<li key={signal}>
<Chip label={signal} onDelete={() => remove(signal)} />
</li>
);
})}
</ul>
</>
)
}
const TrieSelectLevel = ({
prefix = "",
node,
children,
classification
}) => {
const classes = useStyles();
const { selected, add, remove } = useTrieSelect();
const [open, setOpen] = React.useState(false);
const completeChildren = Object.values(node.children).filter(child => child.isComplete);
const descendantCount = `${node.count} ${classification}`;
const handleExpand = () => {
setOpen(open => !open);
};
const handleCheck = (names = [], checked) => {
for (let i = 0; i < names.length; i++) {
if (checked) {
remove(names[i]);
} else {
add(names[i])
}
}
}
const getWords = (node, prepend = prefix, result = new Set()) => {
if (prepend === prefix && node.isComplete) result.add(prepend);
for (const child of Object.values(node.children)) {
const word = (prepend ? prepend + "_" : "") + child.data;
if (child.isComplete) {
result.add(word);
}
if (child.children) {
getWords(child, word, result);
}
}
return Array.from(result);
}
const listItems = (
<>
{children}
{Object.values(node.children).map((child) => {
const fullName = (prefix ? prefix + "_" : "") + child.data;
const isChecked = selected.includes(fullName);
return (
<TrieSelectLevel
prefix={fullName}
node={child}
key={fullName}
classification={classification}
>
{child.isComplete && (
<ListItem className={classes.whiteBackground}>
<ListItemIcon>
<Checkbox checked={isChecked} onClick={() => handleCheck([fullName], isChecked)} />
</ListItemIcon>
<ListItemText primary={fullName} />
</ListItem>
)}
</TrieSelectLevel>
);
})}
</>
);
const isParentOfMultiple = completeChildren.length > 1;
const isAdoptiveParentOfMultiple = completeChildren.length <= 1 && node.count > 1;
if (isParentOfMultiple || isAdoptiveParentOfMultiple) {
const allDescendants = getWords(node, prefix);
const isSelectAll = allDescendants.every(descendant => selected.includes(descendant));
return (
<>
<ListItem onClick={handleExpand} divider>
<ListItemText primary={prefix} secondary={descendantCount} />
<ListItemSecondaryAction>
<Box sx={{ display: "flex", alignItems: "center", gap: "16px" }}>
<FormControlLabel
label="Select All"
labelPlacement="start"
control={
<Checkbox
checked={isSelectAll}
onClick={() => handleCheck(allDescendants, isSelectAll)}
/>
}
/>
{open ? <ExpandLess /> : <ExpandMore />}
</Box>
</ListItemSecondaryAction>
</ListItem>
<Collapse in={open}>
<List>
{listItems}
</List>
</Collapse>
</>
)
}
return listItems;
}

View File

@@ -0,0 +1,54 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import userEvent from '@testing-library/user-event';
import { TrieSelect } from ".";
import addSnapshotSerializer from "../../../utils/snapshot";
const options = [
"ace",
"ace_bev_one",
"ace_bev_two",
"ace_bev_three",
"bev",
"bev_chaz_deep",
"bev_chaz_deep_one",
"bev_chaz_deep_two",
];
describe("TrieSelect", () => {
beforeAll(() => {
addSnapshotSerializer(expect);
});
it("Render", async () => {
const { container } = render(
<TrieSelect
label={"The button label"}
classification="Signal"
options={options}
/>
);
await waitFor(() => {
/* render */
});
expect(container).toMatchSnapshot();
});
it("properly passes payload to callback", async () => {
const mockCallback = jest.fn();
const { getByText } = render(
<TrieSelect
label={"The input label"}
classification="Signal"
options={options}
onChange={mockCallback}
/>
);
const selectAll = getByText("Select All 8");
userEvent.click(selectAll);
expect(mockCallback).toHaveBeenCalledWith(options);
});
});

View File

@@ -0,0 +1,29 @@
import { createContext, useState, useContext, useEffect } from "react";
const TrieSelectContext = createContext();
export const useTrieSelect = () => {
const context = useContext(TrieSelectContext);
if (context === undefined) {
throw new Error("useTrieSelect must be used within a TrieSelectProvider");
}
return context;
}
export const TrieSelectProvider = ({ children, onChange }) => {
const [selected, setSelected] = useState([]);
const add = (id) => setSelected(prev => [...prev, id]);
const remove = (id) => setSelected(prev => prev.filter(select => select !== id));
useEffect(() => {
onChange(selected);
}, [onChange, selected]);
return (
<TrieSelectContext.Provider value={{ selected, setSelected, add, remove }}>
{children}
</TrieSelectContext.Provider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { TrieSelect } from "./TrieSelect";

View File

@@ -0,0 +1,62 @@
class Node {
#count
constructor(data, isComplete = false) {
this.data = data;
this.children = {};
this.isComplete = isComplete;
this.#count = 0;
}
get count() {
return this.#count;
}
incrementCount() {
this.#count += 1;
}
}
class Trie {
constructor(words = []) {
this.root = new Node();
this.populate(words);
}
populate(words) {
for (const word of words) {
this.add(word);
}
}
add(parts, node = this.root) {
if (typeof parts === "string") {
parts = parts.split("_");
}
node.incrementCount();
if (parts.length === 0) {
node.isComplete = true;
return;
}
const part = parts.shift();
if (node.children[part]) {
this.add(parts, node.children[part]);
} else {
const newNode = new Node(part);
node.children[part] = newNode;
this.add(parts, newNode);
}
}
getRoot() {
return this.root;
}
}
export {
Trie,
};

View File

@@ -0,0 +1,24 @@
import { Trie } from "./trie";
describe("Trie", () => {
it("adds words from instantiation", () => {
const trie = new Trie(["AAA_BBB_CCC"]);
const { children: { AAA: root } } = trie.getRoot();
expect(root.data).toBe("AAA");
expect(root.children["BBB"].data).toBe("BBB");
expect(root.children["BBB"].isComplete).toBe(false);
expect(root.children["BBB"].children["CCC"].data).toBe("CCC");
expect(root.children["BBB"].children["CCC"].isComplete).toBe(true);
});
it("adds words with add method", () => {
const trie = new Trie();
trie.add("AAA_BBB_CCC");
const { children: { AAA: root } } = trie.getRoot();
expect(root.data).toBe("AAA");
expect(root.children["BBB"].data).toBe("BBB");
expect(root.children["BBB"].children["CCC"].data).toBe("CCC");
});
});