From 7b2b4e74aa3b953217d478734669428a4dca5376 Mon Sep 17 00:00:00 2001 From: Lanny W Date: Mon, 4 Nov 2024 10:16:03 -0600 Subject: [PATCH 1/7] adding tree component --- .../SchemaIO/components/TreeSelectionView.tsx | 13 +++++ .../src/plugins/SchemaIO/components/index.ts | 1 + .../core/src/plugins/SchemaIO/utils/index.ts | 1 + app/packages/operators/src/types.ts | 23 ++++++++ fiftyone/operators/types.py | 54 +++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx diff --git a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx new file mode 100644 index 0000000000..7b886397c9 --- /dev/null +++ b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx @@ -0,0 +1,13 @@ +import { Box, Grid } from "@mui/material"; +import React from "react"; +import { HeaderView } from "."; +import { getComponentProps, getPath } from "../utils"; + +export default function TreeSelectionView(props) { + const { path, schema, data } = props; + const { view = {}, items } = schema; + + console.log("TreeSelectionView", schema.view.items); + + return ; +} diff --git a/app/packages/core/src/plugins/SchemaIO/components/index.ts b/app/packages/core/src/plugins/SchemaIO/components/index.ts index 6a8bba165c..ad3dd6a31c 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/index.ts +++ b/app/packages/core/src/plugins/SchemaIO/components/index.ts @@ -53,4 +53,5 @@ export { default as TextView } from "./TextView"; export { default as TimelineView } from "./TimelineView"; export { default as ToastView } from "./ToastView"; export { default as TupleView } from "./TupleView"; +export { default as TreeSelectionView } from "./TreeSelectionView"; export { default as UnsupportedView } from "./UnsupportedView"; diff --git a/app/packages/core/src/plugins/SchemaIO/utils/index.ts b/app/packages/core/src/plugins/SchemaIO/utils/index.ts index 672a81b46c..afba080c3f 100644 --- a/app/packages/core/src/plugins/SchemaIO/utils/index.ts +++ b/app/packages/core/src/plugins/SchemaIO/utils/index.ts @@ -111,6 +111,7 @@ const NON_EDITABLE_VIEWS = [ "ProgressView", "TableView", "TagsView", + "TreeSelectionView", ]; export function isCompositeView(schema: SchemaType) { diff --git a/app/packages/operators/src/types.ts b/app/packages/operators/src/types.ts index 1051765ff2..4e5e7f674e 100644 --- a/app/packages/operators/src/types.ts +++ b/app/packages/operators/src/types.ts @@ -782,6 +782,28 @@ export class TupleView extends View { } } +/** + * Operator class for describing a tree node selection {@link View} for an + * operator type. + */ +export class TreeSelectionView extends View { + items: Array; + constructor(options: ViewProps) { + super(options); + this.items = options.items as Array; + console.log("tree selection view", this.items); + this.name = "TreeSelectionView"; + } + + static fromJSON(json) { + console.log("fromJson", json, json.items.map(viewFromJSON)); + return new TreeSelectionView({ + ...json, + items: json.items.map(viewFromJSON), + }); + } +} + /** * Operator class for describing a code block {@link View} for an * operator type. @@ -1258,6 +1280,7 @@ const VIEWS = { OneOfView, ListView, TupleView, + TreeSelectionView, CodeView, ColorView, JSONView, diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 6dbd1677f8..51f433567b 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -610,6 +610,25 @@ def tuple(self, name, *items, **kwargs): self.define_property(name, tuple_type, view=tuple_view, **kwargs) return tuple_type + def tree(self, name, *items, **kwargs): + """Defines a tree property on the object. + + Args: + name: the name of the property + *items: the types of the items in the tree + + Returns: + a :class:`Tree` + """ + tree_selection_view = TreeSelectionView(**kwargs) + tree_type = Object(*items) + print("tree", items, tree_type) + self.define_property( + name, tree_type, view=tree_selection_view, **kwargs + ) + print("tree_type", tree_type) + return tree_type + def clone(self): """Clones the definition of the object. @@ -883,6 +902,27 @@ def to_json(self): } +class Tree(BaseType): + """Represents a tree selection type. + Examples:: + + import fiftyone.operators.types as types + inputs = types.Object() + + Args: + *items: the tree structure of items + """ + + def __init__(self, *items): + self.items = items + + def to_json(self): + return { + "name": self.__class__.__name__, + "items": [item.to_json() for item in self.items], + } + + class Map(BaseType): """Represents a map. @@ -1394,6 +1434,20 @@ def to_json(self): } +class TreeSelectionView(View): + """Displays a tree structure of selection checkboxes of :class:`View` instances.""" + + def __init__(self, *itemsView, **options): + super().__init__(**options) + self.items = itemsView + + def to_json(self): + return { + **super().to_json(), + "items": [item.to_json() for item in self.items], + } + + class CodeView(View): """Displays a code editor. From 8a2578c36762a4cd52fb3b15b665089e0829ce50 Mon Sep 17 00:00:00 2001 From: Lanny W Date: Tue, 5 Nov 2024 21:44:12 -0600 Subject: [PATCH 2/7] add tree selection plugin view component that renders grouped selections --- .../SchemaIO/components/TreeSelectionView.tsx | 309 +++++++++++++++++- 1 file changed, 300 insertions(+), 9 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx index 7b886397c9..2acb0ddcf7 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx @@ -1,13 +1,304 @@ -import { Box, Grid } from "@mui/material"; -import React from "react"; -import { HeaderView } from "."; -import { getComponentProps, getPath } from "../utils"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { Box, Checkbox, FormControlLabel, IconButton } from "@mui/material"; +import React, { useEffect } from "react"; +import { ViewPropsType } from "../utils/types"; -export default function TreeSelectionView(props) { - const { path, schema, data } = props; - const { view = {}, items } = schema; +interface CheckedState { + [key: string]: { + checked: boolean; + indeterminate?: boolean; + }; +} + +interface CollapsedState { + [key: string]: boolean; // true for collapsed, false for expanded +} + +interface CheckboxViewProps { + id: string; + label: string; + isChecked: boolean; + isIndeterminate: boolean; + onChange: (id: string, checked: boolean) => void; +} + +interface TreeNodeProps { + nodeId: string; + childrenIds: (string | [string, string[]])[]; + checkedState: CheckedState; + collapsedState: CollapsedState; + onChange: (id: string, checked: boolean) => void; + onToggleCollapse: (id: string) => void; +} + +export default function TreeSelectionView(props: ViewPropsType) { + const { onChange, path, schema, data } = props; + const { view = {} } = schema; + + if (data == undefined) { + const sampleIds = view?.data.flatMap(([parentId, children]) => { + return children.map((childId) => + typeof childId === "string" ? childId : childId[0] + ); + }); + onChange(path, sampleIds); + } + + const structure = view?.data || []; + + const initialCheckedState: CheckedState = React.useMemo(() => { + const state: CheckedState = { + selectAll: { checked: true, indeterminate: false }, + }; + structure.forEach(([parentId, children]) => { + state[parentId] = { checked: true, indeterminate: false }; + children.forEach((childId) => { + if (typeof childId === "string") { + state[childId] = { checked: true }; + } else { + state[childId[0]] = { checked: true, indeterminate: false }; + childId[1].forEach((nestedChildId) => { + state[nestedChildId] = { checked: true }; + }); + } + }); + }); + return state; + }, [structure]); + + const [checkedState, setCheckedState] = + React.useState(initialCheckedState); + + // Initialize collapsed state for all parents + const initialCollapsedState: CollapsedState = React.useMemo(() => { + const state: CollapsedState = {}; + structure.forEach(([parentId]) => { + state[parentId] = false; // start as expanded + }); + return state; + }, [structure]); + + const [collapsedState, setCollapsedState] = React.useState( + initialCollapsedState + ); + + const handleCheckboxChange = (id: string, isChecked: boolean) => { + setCheckedState((prevState) => { + const updatedState = { + ...prevState, + [id]: { ...prevState[id], checked: isChecked }, + }; + + if (id === "selectAll") { + // Apply the checked/unchecked state to all items when 'selectAll' is toggled + Object.keys(updatedState).forEach((key) => { + updatedState[key] = { checked: isChecked, indeterminate: false }; + }); + } else { + // Update children if the selected ID is a parent + const parentEntry = structure.find(([parentId]) => parentId === id); + if (parentEntry) { + const [, children] = parentEntry; + children.forEach((childId) => { + if (typeof childId === "string") { + updatedState[childId] = { checked: isChecked }; + } else { + const [nestedParentId, nestedChildren] = childId; + updatedState[nestedParentId] = { + checked: isChecked, + indeterminate: false, + }; + nestedChildren.forEach((nestedChildId) => { + updatedState[nestedChildId] = { checked: isChecked }; + }); + } + }); + updatedState[id].indeterminate = false; + } + + // Recursive function to update the checked/indeterminate state of parent nodes + const updateParentState = (parentId) => { + const parentEntry = structure.find( + ([groupId]) => groupId === parentId + ); + if (!parentEntry) return; + + const [, children] = parentEntry; + const allChecked = children.every((childId) => + typeof childId === "string" + ? updatedState[childId].checked + : updatedState[childId[0]].checked + ); + const someChecked = children.some((childId) => + typeof childId === "string" + ? updatedState[childId].checked + : updatedState[childId[0]].checked + ); + + updatedState[parentId] = { + checked: allChecked, + indeterminate: !allChecked && someChecked, + }; + }; + + // Propagate state to parent groups + structure.forEach(([parentId]) => { + updateParentState(parentId); + }); + } + + // Update the selectAll checkbox status based on the final state of all nodes + const allGroupsSelected = structure.every( + ([parentId]) => updatedState[parentId].checked + ); + const anySelected = structure.some( + ([parentId]) => + updatedState[parentId].checked || updatedState[parentId].indeterminate + ); + + updatedState["selectAll"] = { + checked: allGroupsSelected, + indeterminate: anySelected && !allGroupsSelected, + }; + + const selectedSampleIds = Object.keys(updatedState).filter((key) => { + const isSample = + !structure.some(([parentId]) => parentId === key) && + key !== "selectAll"; + return isSample && updatedState[key].checked; // Only checked samples + }); + + // We update the actual output value (ctx.params.value \ data) here. + onChange(path, selectedSampleIds); + + return updatedState; + }); + }; + + // Function to handle expand/collapse toggle + const handleToggleCollapse = (id: string) => { + setCollapsedState((prevState) => ({ + ...prevState, + [id]: !prevState[id], + })); + }; + + const getGroupIdx = ( + groupId: string, + structure: [string, (string | [string, string[]])[]][] + ): number => { + const idx = structure.findIndex(([id]) => id === groupId); + return idx === -1 ? 0 : idx; + }; + + // CheckboxView: Represents a single checkbox (either parent or child) + function CheckboxView({ + id, + label, + isChecked, + isIndeterminate, + onChange, + }: CheckboxViewProps) { + return ( + onChange(id, event.target.checked)} + /> + } + label={label} + /> + ); + } + + // TreeNode: Recursive component to render each parent and its children + function TreeNode({ + nodeId, + childrenIds, + checkedState, + collapsedState, + onChange, + onToggleCollapse, + }: TreeNodeProps) { + const isCollapsed = collapsedState[nodeId] || false; + const count = childrenIds.length; + const title = `Group ${getGroupIdx(nodeId, structure)} • ${count} Samples`; + return ( + + + {/* Render Parent Checkbox */} + + {/* Expand/Collapse Button */} + onToggleCollapse(nodeId)} size="small"> + {isCollapsed ? : } + + + + {/* Render Child Checkboxes if expanded */} + {!isCollapsed && ( + + {childrenIds.map((childId) => + typeof childId === "string" ? ( + + ) : ( + + ) + )} + + )} + + ); + } - console.log("TreeSelectionView", schema.view.items); + // render the full structure + return ( +
+ {/* Select All Checkbox */} + - return ; + {/* Render Tree Structure */} + {structure.map(([parentId, children]) => ( + + ))} +
+ ); } From 0e6e5b8756f4e51e126fd37031bcfe0ae0f6be80 Mon Sep 17 00:00:00 2001 From: Lanny W Date: Tue, 5 Nov 2024 21:57:46 -0600 Subject: [PATCH 3/7] clean up --- app/packages/operators/src/types.ts | 4 ---- fiftyone/operators/types.py | 6 +----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/packages/operators/src/types.ts b/app/packages/operators/src/types.ts index 4e5e7f674e..d8ce727893 100644 --- a/app/packages/operators/src/types.ts +++ b/app/packages/operators/src/types.ts @@ -790,16 +790,12 @@ export class TreeSelectionView extends View { items: Array; constructor(options: ViewProps) { super(options); - this.items = options.items as Array; - console.log("tree selection view", this.items); this.name = "TreeSelectionView"; } static fromJSON(json) { - console.log("fromJson", json, json.items.map(viewFromJSON)); return new TreeSelectionView({ ...json, - items: json.items.map(viewFromJSON), }); } } diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 51f433567b..c343b7652c 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -622,11 +622,9 @@ def tree(self, name, *items, **kwargs): """ tree_selection_view = TreeSelectionView(**kwargs) tree_type = Object(*items) - print("tree", items, tree_type) self.define_property( name, tree_type, view=tree_selection_view, **kwargs ) - print("tree_type", tree_type) return tree_type def clone(self): @@ -1437,14 +1435,12 @@ def to_json(self): class TreeSelectionView(View): """Displays a tree structure of selection checkboxes of :class:`View` instances.""" - def __init__(self, *itemsView, **options): + def __init__(self, **options): super().__init__(**options) - self.items = itemsView def to_json(self): return { **super().to_json(), - "items": [item.to_json() for item in self.items], } From 2fa7265e16a285f7b4bed96fe0c689e38a678268 Mon Sep 17 00:00:00 2001 From: Lanny W Date: Tue, 5 Nov 2024 22:01:44 -0600 Subject: [PATCH 4/7] group id number should starts from 1 not 0 --- .../core/src/plugins/SchemaIO/components/TreeSelectionView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx index 2acb0ddcf7..7b7d6420fd 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx @@ -189,7 +189,7 @@ export default function TreeSelectionView(props: ViewPropsType) { structure: [string, (string | [string, string[]])[]][] ): number => { const idx = structure.findIndex(([id]) => id === groupId); - return idx === -1 ? 0 : idx; + return idx === -1 ? 0 : idx + 1; }; // CheckboxView: Represents a single checkbox (either parent or child) From 54093f242598e3f067ee97dad6c38bab5f8210eb Mon Sep 17 00:00:00 2001 From: Lanny W Date: Tue, 5 Nov 2024 23:14:23 -0600 Subject: [PATCH 5/7] add an example --- fiftyone/operators/types.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index c343b7652c..4559e477e3 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -1433,7 +1433,31 @@ def to_json(self): class TreeSelectionView(View): - """Displays a tree structure of selection checkboxes of :class:`View` instances.""" + """Displays a tree selection checkbox groups. + + Examples:: + + import fiftyone.operators.types as types + + structure = [ + ["group_id_1", ["sample_id_1", "sample_id_2"]], + ["group_id_2", ["sample_id_3", "sample_id_4", "sample_id_5"], ["group_id_8", ["sample_id_6"]]], + ] + + tree_view = types.TreeSelectionView( + data=structure # this data represents the basic group structure; + ) + + panel.view('exact_duplicate_selections', view=tree_view, on_change=self.toggle_select) + + def toggle_select(self, ctx): + selected = ctx.params['value'] + print('selected samples:', selected) + + Args: + data (None): a list of lists representing the tree structure of groups and its children + on_change (None): the operator to execute when the tree selection changes + """ def __init__(self, **options): super().__init__(**options) From 3aaa981af90d9b29da9f7bb47eeb17c27e392486 Mon Sep 17 00:00:00 2001 From: Lanny W Date: Wed, 6 Nov 2024 11:46:32 -0600 Subject: [PATCH 6/7] clean up and fix initialize state issue --- .../SchemaIO/components/TreeSelectionView.tsx | 18 +++++++++++++ fiftyone/operators/types.py | 27 ++----------------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx index 7b7d6420fd..085143b335 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx @@ -3,6 +3,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { Box, Checkbox, FormControlLabel, IconButton } from "@mui/material"; import React, { useEffect } from "react"; import { ViewPropsType } from "../utils/types"; +import { useUnboundState } from "@fiftyone/state"; interface CheckedState { [key: string]: { @@ -70,6 +71,13 @@ export default function TreeSelectionView(props: ViewPropsType) { const [checkedState, setCheckedState] = React.useState(initialCheckedState); + const unboundState = useUnboundState(checkedState); + + // useEffect(() => { + // console.log('unboundState useEffect', unboundState) + // // no need to call onChange here, because the change comes from python side + // }, [data, unboundState]) + // Initialize collapsed state for all parents const initialCollapsedState: CollapsedState = React.useMemo(() => { const state: CollapsedState = {}; @@ -192,6 +200,16 @@ export default function TreeSelectionView(props: ViewPropsType) { return idx === -1 ? 0 : idx + 1; }; + useEffect(() => { + const sampleIds = view?.data.flatMap(([parentId, children]) => { + return children.map((childId) => + typeof childId === "string" ? childId : childId[0] + ); + }); + onChange(path, sampleIds); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // CheckboxView: Represents a single checkbox (either parent or child) function CheckboxView({ id, diff --git a/fiftyone/operators/types.py b/fiftyone/operators/types.py index 4559e477e3..bcaed9795f 100644 --- a/fiftyone/operators/types.py +++ b/fiftyone/operators/types.py @@ -610,18 +610,16 @@ def tuple(self, name, *items, **kwargs): self.define_property(name, tuple_type, view=tuple_view, **kwargs) return tuple_type - def tree(self, name, *items, **kwargs): + def tree(self, name, **kwargs): """Defines a tree property on the object. - Args: name: the name of the property - *items: the types of the items in the tree Returns: a :class:`Tree` """ tree_selection_view = TreeSelectionView(**kwargs) - tree_type = Object(*items) + tree_type = List(String()) self.define_property( name, tree_type, view=tree_selection_view, **kwargs ) @@ -900,27 +898,6 @@ def to_json(self): } -class Tree(BaseType): - """Represents a tree selection type. - Examples:: - - import fiftyone.operators.types as types - inputs = types.Object() - - Args: - *items: the tree structure of items - """ - - def __init__(self, *items): - self.items = items - - def to_json(self): - return { - "name": self.__class__.__name__, - "items": [item.to_json() for item in self.items], - } - - class Map(BaseType): """Represents a map. From 8cf5375b221a0b1a7559572ca25b04f6d18916cb Mon Sep 17 00:00:00 2001 From: Lanny W Date: Wed, 6 Nov 2024 12:58:47 -0600 Subject: [PATCH 7/7] remove console --- .../src/plugins/SchemaIO/components/TreeSelectionView.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx index 085143b335..0679d6e97e 100644 --- a/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx +++ b/app/packages/core/src/plugins/SchemaIO/components/TreeSelectionView.tsx @@ -73,11 +73,6 @@ export default function TreeSelectionView(props: ViewPropsType) { const unboundState = useUnboundState(checkedState); - // useEffect(() => { - // console.log('unboundState useEffect', unboundState) - // // no need to call onChange here, because the change comes from python side - // }, [data, unboundState]) - // Initialize collapsed state for all parents const initialCollapsedState: CollapsedState = React.useMemo(() => { const state: CollapsedState = {};