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:
205
src/components/Controls/TrieSelect/TrieSelect.jsx
Normal file
205
src/components/Controls/TrieSelect/TrieSelect.jsx
Normal 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;
|
||||
}
|
||||
54
src/components/Controls/TrieSelect/TrieSelect.test.tsx
Normal file
54
src/components/Controls/TrieSelect/TrieSelect.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
29
src/components/Controls/TrieSelect/TrieSelectContext.js
Normal file
29
src/components/Controls/TrieSelect/TrieSelectContext.js
Normal 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
1
src/components/Controls/TrieSelect/index.js
Normal file
1
src/components/Controls/TrieSelect/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { TrieSelect } from "./TrieSelect";
|
||||
62
src/components/Controls/TrieSelect/trie.js
Normal file
62
src/components/Controls/TrieSelect/trie.js
Normal 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,
|
||||
};
|
||||
24
src/components/Controls/TrieSelect/trie.test.js
Normal file
24
src/components/Controls/TrieSelect/trie.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user