diff --git a/drc-portals/app/data/c2m2/graph/api/pathway/route.ts b/drc-portals/app/data/c2m2/graph/api/pathway/route.ts
index 130c0744..e1112674 100644
--- a/drc-portals/app/data/c2m2/graph/api/pathway/route.ts
+++ b/drc-portals/app/data/c2m2/graph/api/pathway/route.ts
@@ -1,6 +1,7 @@
import { Session } from "neo4j-driver";
import { NextRequest } from "next/server";
+import { UNIQUE_TO_GENERIC_REL } from "@/lib/neo4j/constants";
import {
createPathwaySearchAllPathsCypher,
createUpperPageBoundCypher,
@@ -19,7 +20,7 @@ import {
RelationshipResult,
TreeParseResult,
} from "@/lib/neo4j/types";
-import { parsePathwayTree } from "@/lib/neo4j/utils";
+import { isRelationshipResult, parsePathwayTree } from "@/lib/neo4j/utils";
const MAX_LIMIT = 1000;
const MAX_PAGE_SIBLINGS = 9;
@@ -172,9 +173,20 @@ export async function POST(request: NextRequest) {
);
}),
]);
- const paths: PathwaySearchResultRow[] = pathwaySearchResult.map((record) =>
- Object.values(record.toObject())
- );
+ const paths: PathwaySearchResultRow[] = pathwaySearchResult
+ .map((record) => Object.values(record.toObject()))
+ .map((val) =>
+ val.map((column) => {
+ if (isRelationshipResult(column)) {
+ return {
+ ...column,
+ type: UNIQUE_TO_GENERIC_REL.get(column.type) || "Unkown", // Transform the unique rels back to their generic format for UI presentation
+ };
+ } else {
+ return column;
+ }
+ })
+ );
const upperPageBound = upperPageBoundResult.toObject().upperPageBound;
// If we reach the end of the table, we can fix the lower bound to maintain a constant number of page items
diff --git a/drc-portals/app/data/documentation/markdown/GQI.mdx b/drc-portals/app/data/documentation/markdown/GQI.mdx
index ca573199..a09ded96 100644
--- a/drc-portals/app/data/documentation/markdown/GQI.mdx
+++ b/drc-portals/app/data/documentation/markdown/GQI.mdx
@@ -61,7 +61,7 @@ The UI is a clean, intuitive, interactive canvas with minimal control tools. The
-
*__Figure 5.__ Clean, intuitive, minimalistic graph query user interface. The canvas shows 1. Input field, a collapsible toolbar, including 2. Start over, 3. Export Cypher 4. Download Pathway, 5. Upload Pathway, 6. Help, 7. Zoom In, 8. Zoom Out and 9. Fit Graph.*
+
*__Figure 5.__ Clean, intuitive, minimalistic graph query user interface. The canvas shows 1. Input field, a collapsible toolbar, including 2. Start over, 2. Stop Loading 4. Export Cypher 5. Download Pathway, 6. Upload Pathway, 7. Export PNG, 8. Help 9. Zoom In, 10. Zoom Out and 11. Fit Graph.*
The 'Expand' option will cue the user to explore the possible relationships based on the underlying schema and data availability for the entity/search path. With the 'Filter' option, one can subset the data by instantiating a specific value to a class object (ex, asthma for the 'Disease' class). With the 'Prune' option, one can edit the search path by removing a node(s) and their relationship(s).
diff --git a/drc-portals/app/data/graph/help/page.mdx b/drc-portals/app/data/graph/help/page.mdx
index d6dd03f6..e492f62a 100644
--- a/drc-portals/app/data/graph/help/page.mdx
+++ b/drc-portals/app/data/graph/help/page.mdx
@@ -15,31 +15,33 @@ You can view an interactive version of the C2M2 Graph Schema [here](/data/graph/
## Quick Start Tips
-### __1. Start with a Keyword__
+### **1. Start with a Keyword**
Enter a keyword (e.g., human, blood, asthma, RNA-seq, TP53, aspirin) to search ontology-encoded metadata, such as species, anatomy, disease/phenotype, gene, compound, and assay, to discover entities like Subject, Biosample, and File.

-### __2. Build as You Explore__
+### **2. Build as You Explore**
-Use node menu options (point the cursor/mouse to a node) — __Expand__ (to explore related entities), __Filter__ (to narrow by values), and __Prune__ (to trim paths) — to build your query one step at a time.
+Use node menu options (point the cursor/mouse to a node) — **Expand** (to explore related entities), **Filter** (to narrow by values), and **Prune** (to trim paths) — to build your query one step at a time.
-
+
+
-### __3. View & Download__
+### **3. View & Download**
-See results in __Tabular__ or __Network View__, and export them as JSON to share, revisit, or analyze further.
+See results in **Tabular** or **Network View**, and export them as JSON to share, revisit, or analyze further.

}>
- BACK TO SEARCH
+ startIcon={}
+ >
+ BACK TO SEARCH
diff --git a/drc-portals/components/prototype/components/GraphPathway.tsx b/drc-portals/components/prototype/components/GraphPathway.tsx
index 7e1c4d00..a9be0bcf 100644
--- a/drc-portals/components/prototype/components/GraphPathway.tsx
+++ b/drc-portals/components/prototype/components/GraphPathway.tsx
@@ -33,8 +33,10 @@ import {
PathwaySearchContextProps,
} from "../contexts/PathwaySearchContext";
import {
+ ColumnData,
ConnectionMenuItem,
PathwaySearchNode,
+ PathwaySearchNodeData,
} from "../interfaces/pathway-search";
import { PathwaySearchElement } from "../types/pathway-search";
import {
@@ -245,7 +247,35 @@ export default function GraphPathway() {
const handleReturnBtnClick = () => {
setShowResults(false);
- };
+ }
+
+ const handleVisibilityChange = useCallback((columns: ColumnData[]) => {
+ if (tree !== undefined) {
+ const newElements = [
+ ...searchElements.map((element) => {
+ if (isPathwaySearchEdgeElement(element)) {
+ return deepCopyPathwaySearchEdge(element);
+ } else {
+ for (let col of columns) {
+ if (col.key === element.data.id) {
+ return deepCopyPathwaySearchNode(
+ element,
+ { visible: col.visible },
+ [col.visible ? "solid" : "transparent"],
+ [col.visible ? "transparent" : "solid"]
+ );
+ }
+ }
+ // This should never happen
+ console.warn(`Could not find corresponding column for node element with ID: ${element.data.id}`)
+ return deepCopyPathwaySearchNode(element);
+ }
+ }),
+ ];
+ setSearchElements(newElements);
+ setTree(createTree(newElements));
+ }
+ }, [tree]);
const handleSearchBarSubmit = (cvTerm: NodeResult) => {
// TODO: Direct node results *should* always have at least one label since they are required on all Neo4j nodes, so maybe this check
@@ -263,16 +293,14 @@ export default function GraphPathway() {
return;
}
- const initialNode = createPathwaySearchNode(
- {
- id: cvTerm.uuid,
- dbLabel: cvTerm.labels[0],
- props: {
- name: [cvTerm.properties.name],
- },
+ const initialNode = createPathwaySearchNode({
+ id: cvTerm.uuid,
+ dbLabel: cvTerm.labels[0],
+ visible: true,
+ props: {
+ name: [cvTerm.properties.name],
},
- ["path-element"]
- );
+ });
let initialTree: PathwayNode;
try {
@@ -368,13 +396,11 @@ export default function GraphPathway() {
const candidateSearchElements = [
// The newly connected node
- createPathwaySearchNode(
- {
- id: item.nodeId,
- dbLabel: item.label,
- },
- ["path-element"]
- ),
+ createPathwaySearchNode({
+ id: item.nodeId,
+ dbLabel: item.label,
+ visible: true,
+ }),
...fallbackElements,
// And the newly connected edge
createPathwaySearchEdge(
@@ -386,9 +412,7 @@ export default function GraphPathway() {
item.direction === Direction.OUTGOING ? item.target : item.source,
type: item.type,
},
- item.direction === Direction.INCOMING
- ? ["source-arrow-only", "path-element"]
- : ["path-element"]
+ item.direction === Direction.INCOMING ? ["source-arrow-only"] : []
),
];
updateCounts(candidateSearchElements, loadingElements, fallbackElements);
@@ -531,6 +555,37 @@ export default function GraphPathway() {
}
}, [searchElements]);
+ const handleShowHide = useCallback(
+ (node: NodeSingular) => {
+ const nodeData: PathwaySearchNodeData = node.data();
+ const nodeClasses = node.classes();
+ const newVisibility = !nodeData.visible;
+ const newNode: PathwaySearchNode = {
+ classes: nodeClasses
+ // Don't duplicate the solid/transparent classes, and remove the "hovered" class so the node immediately becomes transparent if hidden
+ .filter(cls => !["solid", "transparent", "hovered"].includes(cls))
+ .concat([newVisibility ? "solid" : "transparent"]),
+ data: {
+ ...nodeData,
+ visible: newVisibility,
+ },
+ };
+ const newElements = [
+ newNode,
+ ...searchElements
+ .filter((el) => el.data.id !== nodeData.id)
+ .map((element) =>
+ isPathwaySearchEdgeElement(element)
+ ? deepCopyPathwaySearchEdge(element)
+ : deepCopyPathwaySearchNode(element)
+ ),
+ ];
+ setSearchElements(newElements);
+ setTree(createTree(newElements));
+ },
+ [searchElements]
+ );
+
// On the initial load of the page, populate the pathway with an initial node if one exists in the query params
useEffect(() => {
const id = searchParams.get("id");
@@ -556,14 +611,17 @@ export default function GraphPathway() {
}, []);
return (
-
+
{showResults && tree !== undefined ? (
) : (
@@ -576,6 +634,7 @@ export default function GraphPathway() {
onPruneSelected={handlePruneSelected}
onPruneConfirm={handlePruneConfirm}
onPruneCancel={handlePruneCancel}
+ onShowHideSelected={handleShowHide}
onDownload={handleExport}
onUpload={handleImport}
onCopyCypher={handleCopyCypher}
diff --git a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx
index 05ff2531..366d3ab9 100644
--- a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx
+++ b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwayResults.tsx
@@ -33,13 +33,14 @@ import TableViewSkeleton from "./PathwayResults/TableViewSkeleton";
interface GraphPathwayResultsProps {
tree: PathwayNode;
+ onVisibilityChange: (columns: ColumnData[]) => void;
onReturnBtnClick: () => void;
}
export default function GraphPathwayResults(
cmpProps: GraphPathwayResultsProps
) {
- const { tree, onReturnBtnClick } = cmpProps;
+ const { tree, onVisibilityChange, onReturnBtnClick } = cmpProps;
const [paths, setPaths] = useState([]);
const [page, setPage] = useState(1);
const [lowerPageBound, setLowerPageBound] = useState(Math.max(page - 5, 1));
@@ -79,6 +80,10 @@ export default function GraphPathwayResults(
return { data: await response.json(), status: response.status };
};
+ const handleReturnBtnClick = () => {
+ onReturnBtnClick()
+ }
+
const handlePageChange = useCallback(
async (newPage: number) => {
setLoading(true);
@@ -167,7 +172,7 @@ export default function GraphPathwayResults(
[tree, limit, page, columns]
);
- const handleColumnChange = useCallback(
+ const handleColumnPropertyChange = useCallback(
async (changedColumn: number, changes: Partial) => {
try {
const newColumns = columns.map((col, idx) =>
@@ -200,6 +205,10 @@ export default function GraphPathwayResults(
[tree, limit, page, columns, orderBy, order]
);
+ const handleColumnVisibilityChange = (columns: ColumnData[]) => {
+ onVisibilityChange(columns);
+ }
+
const handleDownloadAllClicked = useCallback(async () => {
try {
const columnData = orderBy === undefined ? undefined : columns[orderBy];
@@ -233,10 +242,13 @@ export default function GraphPathwayResults(
};
useEffect(() => {
+ const newColumns = getColumnDataFromTree(tree)
+ const columnData =
+ orderBy === undefined ? undefined : newColumns[orderBy];
setLoading(true);
- setColumns(getColumnDataFromTree(tree));
+ setColumns(newColumns);
Promise.all([
- getPathwaySearchResults(tree, page, limit),
+ getPathwaySearchResults(tree, page, limit, columnData?.key, columnData?.displayProp, order),
]).then(([searchResult]) => {
setLoading(false);
setPaths(searchResult.data.paths);
@@ -266,7 +278,7 @@ export default function GraphPathwayResults(
lowerPageBound={lowerPageBound}
upperPageBound={upperPageBound}
columns={columns}
- onReturnBtnClick={onReturnBtnClick}
+ onReturnBtnClick={handleReturnBtnClick}
/>
) : (
)}
@@ -291,7 +304,7 @@ export default function GraphPathwayResults(
diff --git a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx
index ba7c57b7..236f5860 100644
--- a/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx
+++ b/drc-portals/components/prototype/components/PathwaySearch/GraphPathwaySearch.tsx
@@ -4,11 +4,13 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
-import HelpIcon from '@mui/icons-material/Help';
-import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import HelpIcon from "@mui/icons-material/Help";
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import SearchIcon from "@mui/icons-material/Search";
-import StopIcon from '@mui/icons-material/Stop';
+import StopIcon from "@mui/icons-material/Stop";
+import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
+import VisibilityIcon from "@mui/icons-material/Visibility";
import {
Box,
Button,
@@ -38,7 +40,11 @@ import {
useState,
} from "react";
-import { ADMIN_LABELS, FILTER_LABELS, NAME_FILTER_LABELS, TERM_LABELS } from "@/lib/neo4j/constants";
+import {
+ ADMIN_LABELS,
+ FILTER_LABELS,
+ TERM_LABELS,
+} from "@/lib/neo4j/constants";
import { NodeResult } from "@/lib/neo4j/types";
import {
@@ -52,16 +58,30 @@ import {
} from "../../constants/pathway-search";
import { SearchBarContainer } from "../../constants/search-bar";
import { CFDE_DARK_BLUE, VisuallyHiddenInput } from "../../constants/shared";
-import { ADD_CONNECTION_ITEM_ID, PRUNE_ID, SHOW_FILTERS_ID } from "../../constants/cxt-menu";
+import {
+ ADD_CONNECTION_ITEM_ID,
+ SHOW_HIDE_NODE_ID,
+ PRUNE_ID,
+ SHOW_FILTERS_ID,
+} from "../../constants/cxt-menu";
import { CxtMenuItem } from "../../interfaces/cxt-menu";
import { CytoscapeEvent } from "../../interfaces/cy";
import {
ConnectionMenuItem,
PathwaySearchNode,
} from "../../interfaces/pathway-search";
-import { CustomToolbarFnFactory } from "../../types/cy";
-import { PathwaySearchElement, PropertyConfigs, PropertyValueType } from "../../types/pathway-search";
-import { getRootFromElements, isPathwaySearchEdgeElement, updatePathwayNodeProps } from "../../utils/pathway-search";
+import { CustomToolbarFnFactory, CytoscapeReference } from "../../types/cy";
+import {
+ PathwaySearchElement,
+ PropertyConfigs,
+ PropertyValueType,
+} from "../../types/pathway-search";
+import { downloadChartPNG } from "../../utils/cy";
+import {
+ getRootFromElements,
+ isPathwaySearchEdgeElement,
+ updatePathwayNodeProps,
+} from "../../utils/pathway-search";
import CytoscapeChart from "../CytoscapeChart/CytoscapeChart";
import ChartCxtMenuItem from "../CytoscapeChart/ChartCxtMenuItem";
@@ -83,6 +103,7 @@ interface GraphPathwaySearchProps {
onPruneSelected: (node: NodeSingular) => void;
onPruneConfirm: () => void;
onPruneCancel: () => void;
+ onShowHideSelected: (node: NodeSingular) => void;
onReset: () => void;
onStopLoading: () => void;
onDownload: () => void;
@@ -103,6 +124,7 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
onPruneSelected,
onPruneConfirm,
onPruneCancel,
+ onShowHideSelected,
onReset,
onStopLoading,
onDownload,
@@ -157,7 +179,7 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
const nodesToAnimate = loadingNodes.reduce(
(prev, curr) => cy.getElementById(curr).union(prev),
cy.collection()
- )
+ );
// Cytoscape crashes if you try to animate empty collections
if (nodesToAnimate.size() > 0) {
@@ -218,13 +240,20 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
);
const handleNodeFilterChange = useCallback(
- (value: PropertyValueType, propName: K) => {
+ (
+ value: PropertyValueType,
+ propName: K
+ ) => {
if (selectedNode !== undefined) {
const newSelectedNode: PathwaySearchNode = {
classes: [...(selectedNode.classes || [])],
data: {
...selectedNode.data,
- props: updatePathwayNodeProps(selectedNode.data.props, propName, value),
+ props: updatePathwayNodeProps(
+ selectedNode.data.props,
+ propName,
+ value
+ ),
},
};
setSelectedNode(newSelectedNode);
@@ -252,31 +281,51 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
const showFilterNode = useCallback(
(event: EventObjectNode) => {
const root = getRootFromElements(elements);
- return event.target.data("id") !== root?.data.id && FILTER_LABELS.has(event.target.data("dbLabel"))
- }, [elements]
+ return (
+ event.target.data("id") !== root?.data.id &&
+ FILTER_LABELS.has(event.target.data("dbLabel"))
+ );
+ },
+ [elements]
);
+ const handleShowHideNodeSelected = (event: EventObjectNode) => {
+ onShowHideSelected(event.target);
+ }
- const showExpandNode = useCallback((event: EventObjectNode) => {
- const root = getRootFromElements(elements);
- const nodeLabel: string = event.target.data("dbLabel");
- const nodeId: string = event.target.data("id");
-
- // If the node is the root...
- if (nodeId === root?.data.id) {
- if (elements.filter(isPathwaySearchEdgeElement).some((edge) => edge.data.source === nodeId || edge.data.target === nodeId)) {
- // Don't allow expansion if already has some connection
- return false
- }
- } else {
- // If the node is NOT the root...
- if (ADMIN_LABELS.includes(nodeLabel) || TERM_LABELS.includes(nodeLabel)) {
- // Don't allow expansion if the node has an admin label or a term label
- return false;
+ const showExpandNode = useCallback(
+ (event: EventObjectNode) => {
+ const root = getRootFromElements(elements);
+ const nodeLabel: string = event.target.data("dbLabel");
+ const nodeId: string = event.target.data("id");
+
+ // If the node is the root...
+ if (nodeId === root?.data.id) {
+ if (
+ elements
+ .filter(isPathwaySearchEdgeElement)
+ .some(
+ (edge) =>
+ edge.data.source === nodeId || edge.data.target === nodeId
+ )
+ ) {
+ // Don't allow expansion if already has some connection
+ return false;
+ }
+ } else {
+ // If the node is NOT the root...
+ if (
+ ADMIN_LABELS.includes(nodeLabel) ||
+ TERM_LABELS.includes(nodeLabel)
+ ) {
+ // Don't allow expansion if the node has an admin label or a term label
+ return false;
+ }
}
- }
- return true;
- }, [elements]);
+ return true;
+ },
+ [elements]
+ );
const handlePruneNodeSelected = (event: EventObjectNode) => {
setSnackbarOpen(true);
@@ -299,36 +348,45 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
() => {
return (
- {
- elements.length === 0
- ?
+ {elements.length === 0 ? (
+
+
+
+ ) : (
+
+
- :
-
-
-
-
- }
-
+
+ )}
);
},
() => {
return (
- {
- loadingNodes.length === 0
- ?
+ {loadingNodes.length === 0 ? (
+
+
+
+ ) : (
+
+
- :
-
-
-
-
- }
-
+
+ )}
);
},
@@ -384,6 +442,12 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
);
},
+ (cyRef: CytoscapeReference) =>
+ downloadChartPNG(
+ "search-chart-toolbar-download-png",
+ "Download PNG",
+ cyRef
+ ),
() => (
{
- const targetNode = event.target.source().hasClass("path-element")
- ? event.target.target()
- : event.target.source();
- handleSelectedNodeChange(targetNode.id(), event.cy);
- },
- },
{
event: "cxttap",
target: "node",
@@ -444,34 +498,6 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
handleSelectedNodeChange(event.target.id(), event.cy);
},
},
- {
- event: "mouseover",
- target: "node",
- callback: (event: EventObjectNode) => {
- event.target.neighborhood().addClass("solid");
- },
- },
- {
- event: "mouseout",
- target: "node",
- callback: (event: EventObjectNode) => {
- event.target.neighborhood().removeClass("solid");
- },
- },
- {
- event: "mouseover",
- target: "edge",
- callback: (event: EventObjectEdge) => {
- event.target.connectedNodes().addClass("solid");
- },
- },
- {
- event: "mouseout",
- target: "edge",
- callback: (event: EventObjectEdge) => {
- event.target.connectedNodes().removeClass("solid");
- },
- },
],
[handleSelectedNodeChange]
);
@@ -479,55 +505,94 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
const nodeCxtMenuItems: CxtMenuItem[] = useMemo(
() => [
{
- content: ,
+ content: (
+
+ ),
tree: {
id: ADD_CONNECTION_ITEM_ID,
children: [],
open: false,
- }
+ },
},
{
- content: (
-
-
- Filters
-
- )}
- action={handleFilterNodeSelected}
- showFn={showFilterNode}
- >,
+ content: (
+ (
+
+
+ Filters
+
+ )}
+ action={handleFilterNodeSelected}
+ showFn={showFilterNode}
+ >
+ ),
tree: {
id: SHOW_FILTERS_ID,
children: [],
open: false,
- }
+ },
},
{
- content: (
-
-
- Prune
-
- )}
- action={handlePruneNodeSelected}
- >,
+ content: (
+ (
+
+
+ {event.target.data("visible") ? (
+ <>
+ {" "}
+ Hide Node
+ >
+ ) : (
+ <>
+ {" "}
+ Show Node
+ >
+ )}
+
+
+ )}
+ action={handleShowHideNodeSelected}
+ >
+ ),
+ tree: {
+ id: SHOW_HIDE_NODE_ID,
+ children: [],
+ open: false,
+ },
+ },
+ {
+ content: (
+ (
+
+
+ Prune
+
+ )}
+ action={handlePruneNodeSelected}
+ >
+ ),
tree: {
id: PRUNE_ID,
children: [],
open: false,
- }
+ },
},
],
[elements]
@@ -594,14 +659,16 @@ export default function GraphPathwaySearch(cmpProps: GraphPathwaySearchProps) {
) : null}
{loadingInitial ? (
-
+
diff --git a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/ColumnsPanel.tsx b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/ColumnsPanel.tsx
new file mode 100644
index 00000000..91d33eea
--- /dev/null
+++ b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/ColumnsPanel.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import {
+ Box,
+ FormControl,
+ FormControlLabel,
+ FormGroup,
+ Switch,
+} from "@mui/material";
+
+import { ColumnData } from "@/components/prototype/interfaces/pathway-search";
+
+interface ColumnsPanelProps {
+ columns: ColumnData[];
+ onSwitch: (column: number) => void;
+ // onShowAllClicked: () => void;
+ // onHideAllClicked: () => void;
+}
+
+export default function ColumnsPanel(cmpProps: ColumnsPanelProps) {
+ const { columns, onSwitch } = cmpProps;
+
+ return (
+ <>
+ {/* Header */}
+ {/* */}
+ {/* Content */}
+
+
+
+ {columns.map((col, idx) => (
+ onSwitch(idx)} color="secondary" />
+ }
+ label={col.label}
+ labelPlacement="end"
+ />
+ ))}
+
+
+
+ {/* Footer */}
+ {/*
+
+
+ */}
+ >
+ );
+}
diff --git a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx
index 4fe7db52..0202cb5a 100644
--- a/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx
+++ b/drc-portals/components/prototype/components/PathwaySearch/PathwayResults/TableView.tsx
@@ -6,11 +6,13 @@ import DownloadIcon from "@mui/icons-material/Download";
import IndeterminateCheckBoxIcon from "@mui/icons-material/IndeterminateCheckBox";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import MoreVertIcon from "@mui/icons-material/MoreVert";
+import ViewColumnIcon from "@mui/icons-material/ViewColumn";
+import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
+import VisibilityIcon from "@mui/icons-material/Visibility";
import {
Box,
Button,
Checkbox,
- CircularProgress,
FormControl,
IconButton,
ListItemIcon,
@@ -51,6 +53,8 @@ import { getPropertyListFromNodeLabel } from "@/components/prototype/utils/pathw
import { downloadBlob } from "@/components/prototype/utils/shared";
import ReturnBtn from "../ReturnBtn";
+
+import ColumnsPanel from "./ColumnsPanel";
import PathwayTablePagination from "./PathwayTablePagination";
interface TableViewProps {
@@ -66,7 +70,8 @@ interface TableViewProps {
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
onOrderByChange: (column: number | undefined, order: Order) => void;
- onColumnChange: (column: number, changes: Partial) => void;
+ onColumnPropertyChange: (column: number, changes: Partial) => void;
+ onColumnVisibilityChange: (columns: ColumnData[]) => void;
onDownloadAll: () => Promise;
}
@@ -84,7 +89,8 @@ export default function TableView(cmpProps: TableViewProps) {
onPageChange,
onLimitChange,
onOrderByChange,
- onColumnChange,
+ onColumnPropertyChange,
+ onColumnVisibilityChange,
onDownloadAll,
} = cmpProps;
const [selected, setSelected] = useState(
@@ -97,14 +103,19 @@ export default function TableView(cmpProps: TableViewProps) {
);
const [colMenuColumn, setColMenuColumn] = useState();
const colMenuOpen = Boolean(colMenuAnchorEl);
+ const [colVisibilityMenuAnchorEl, setColVisibilityMenuAnchorEl] =
+ useState(null);
+ const colVisibilityMenuOpen = Boolean(colVisibilityMenuAnchorEl);
- const drsBundleData = useMemo(() =>
- data
- .filter((_, idx) => selected[idx])
- .flat()
- .filter((element) => !isRelationshipResult(element))
- .map(node => node.properties)
- , [data, selected])
+ const drsBundleData = useMemo(
+ () =>
+ data
+ .filter((_, idx) => selected[idx])
+ .flat()
+ .filter((element) => !isRelationshipResult(element))
+ .map((node) => node.properties),
+ [data, selected]
+ );
const getColumnHeaderText = (column: ColumnData) => {
return (
@@ -142,6 +153,16 @@ export default function TableView(cmpProps: TableViewProps) {
setColMenuAnchorEl(null);
};
+ const handleColVisibilityMenuClick = (
+ event: React.MouseEvent
+ ) => {
+ setColVisibilityMenuAnchorEl(event.currentTarget);
+ };
+
+ const handleColVisibilityMenuClose = () => {
+ setColVisibilityMenuAnchorEl(null);
+ };
+
const colMenuFnWrapper = (
fn: (...args: Args) => void,
...args: Args
@@ -155,11 +176,21 @@ export default function TableView(cmpProps: TableViewProps) {
};
const handleColMenuPropertyUpdate = (column: number, property: string) => {
- colMenuFnWrapper(onColumnChange, column, {
+ colMenuFnWrapper(onColumnPropertyChange, column, {
displayProp: property,
});
};
+ const handleColumnVisibilitySwitch = useCallback(
+ (changedColumn: number) => {
+ const newColumns = columns.map((col, idx) =>
+ idx === changedColumn ? { ...col, visible: !columns[changedColumn].visible } : { ...col }
+ );
+ onColumnVisibilityChange(newColumns);
+ },
+ [columns]
+ );
+
const handleSelectAllClick = (event: React.ChangeEvent) => {
if (event.target.checked) {
setSelected(new Array(data.length).fill(true));
@@ -174,8 +205,10 @@ export default function TableView(cmpProps: TableViewProps) {
let newOrderBy: number | undefined = column;
if (sortedColumn === column) {
- if (order === "asc") newOrder = "desc"; // order column by desc
- else if (order === "desc") newOrderBy = undefined; // unorder column
+ if (order === "asc")
+ newOrder = "desc"; // order column by desc
+ else if (order === "desc")
+ newOrderBy = undefined; // unorder column
else newOrder = "asc"; // order column by asc
} else {
newOrder = "asc"; // order column by asc
@@ -206,15 +239,50 @@ export default function TableView(cmpProps: TableViewProps) {
return (
<>
- {/* Start table */}
+ {/* Start table header */}
-
+
+ {/*Table Toolbar*/}
+
+
+ }
+ onClick={handleColVisibilityMenuClick}
+ >
+ Columns
+
+
+
- {/* width: 1% forces minimal use of space */}
#
- {columns.map((col, idx) => (
-
-
- handleSortBtnClicked(event, idx)}
+ {columns
+ .map((col, idx) => (
+
+
- {getColumnHeaderText(col)}
- {sortedColumn === idx ? (
-
- {order === "desc"
- ? "sorted descending"
- : "sorted ascending"}
-
- ) : null}
-
- handleColMenuClick(event, idx)}
- >
-
-
-
-
- ))}
+ handleSortBtnClicked(event, idx)}
+ >
+ {getColumnHeaderText(col)}
+ {sortedColumn === idx ? (
+
+ {order === "desc"
+ ? "sorted descending"
+ : "sorted ascending"}
+
+ ) : null}
+
+ handleColMenuClick(event, idx)}
+ >
+
+
+
+
+ ))
+ .filter((_, j) => columns[j].visible) // Skip hidden columns
+ }
@@ -282,16 +355,21 @@ export default function TableView(cmpProps: TableViewProps) {
/>
{(page - 1) * limit + i + 1}
- {row
- .filter((col) => !isRelationshipResult(col))
- .map((nodeCol, j) => (
-
- {columns[j].valueGetter(
- nodeCol as NodeResult,
- columns[j].displayProp
- )}
-
- ))}
+ {
+ row
+ .filter((col) => !isRelationshipResult(col)) // Skip relationship data (this shrinks the row width to match the length of `columns`)
+ .map((nodeCol, j) => {
+ const visibleColumns = columns.filter(col => col.visible);
+ return (
+
+ {visibleColumns[j].valueGetter(
+ nodeCol as NodeResult,
+ visibleColumns[j].displayProp
+ )}
+
+ )
+ })
+ }
))}
@@ -365,6 +443,26 @@ export default function TableView(cmpProps: TableViewProps) {
+
+ {/* Individual Column Menu */}