diff --git a/src/web/common/components/ContextMenu/ChangeEdgeTypeMenuItem.tsx b/src/web/common/components/ContextMenu/ChangeEdgeTypeMenuItem.tsx index e1365092..baa38fa3 100644 --- a/src/web/common/components/ContextMenu/ChangeEdgeTypeMenuItem.tsx +++ b/src/web/common/components/ContextMenu/ChangeEdgeTypeMenuItem.tsx @@ -5,6 +5,7 @@ import { getSameCategoryEdgeTypes } from "@/common/edge"; import { ContextMenuItem } from "@/web/common/components/ContextMenu/CloseOnClickMenuItem"; import { useSessionUser } from "@/web/common/hooks"; import { changeEdgeType } from "@/web/topic/store/actions"; +import { useIsTableEdge } from "@/web/topic/store/edgeHooks"; import { useUserCanEditTopicData } from "@/web/topic/store/userHooks"; import { Edge } from "@/web/topic/utils/graph"; @@ -17,7 +18,11 @@ export const ChangeEdgeTypeMenuItem = ({ edge, parentMenuOpen }: Props) => { const { sessionUser } = useSessionUser(); const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username); + const isTableEdge = useIsTableEdge(edge.id); + if (!userCanEditTopicData) return <>; + // don't allow modifying edges that are part of the table, because they should always exist as long as their nodes do + if (isTableEdge) return <>; return ( <> diff --git a/src/web/common/components/ContextMenu/ContextMenu.tsx b/src/web/common/components/ContextMenu/ContextMenu.tsx index 1004c7db..c39cfc3e 100644 --- a/src/web/common/components/ContextMenu/ContextMenu.tsx +++ b/src/web/common/components/ContextMenu/ContextMenu.tsx @@ -9,6 +9,9 @@ import { DeleteEdgeMenuItem } from "@/web/common/components/ContextMenu/DeleteEd import { DeleteNodeMenuItem } from "@/web/common/components/ContextMenu/DeleteNodeMenuItem"; import { HideMenuItem } from "@/web/common/components/ContextMenu/HideMenuItem"; import { OnlyShowNodeAndNeighborsMenuItem } from "@/web/common/components/ContextMenu/OnlyShowNodeAndNeighborsMenuItem"; +import { ViewContextMenuItem } from "@/web/common/components/ContextMenu/ViewContextMenuItem"; +import { ViewDetailsMenuItem } from "@/web/common/components/ContextMenu/ViewDetailsMenuItem"; +import { ViewTableMenuItem } from "@/web/common/components/ContextMenu/ViewTableMenuItem"; import { closeContextMenu } from "@/web/common/store/contextMenuActions"; import { useAnchorPosition, useContextMenuContext } from "@/web/common/store/contextMenuStore"; @@ -20,29 +23,28 @@ export const ContextMenu = () => { const isOpen = Boolean(anchorPosition); + const contextNode = contextMenuContext.node; + const contextEdge = contextMenuContext.edge; + const contextPart = contextNode ?? contextEdge; + // create these based on what's set in the context const menuItems = [ + // view actions (so that this functionality is still available if indicators are hidden) + contextPart && , + contextNode?.type === "problem" && , + contextPart && , + // CRUD actions - !contextMenuContext.node && !contextMenuContext.edge && ( - - ), - contextMenuContext.node && ( - - ), - contextMenuContext.edge && ( - - ), - contextMenuContext.node && , - contextMenuContext.edge && , + contextPart === undefined && , + contextNode && , + contextEdge && , + contextNode && , + contextEdge && , // show/hide actions - contextMenuContext.node && ( - - ), - contextMenuContext.node && ( - - ), - contextMenuContext.node && , + contextNode && , + contextNode && , + contextNode && , // ensure there's never an empty context menu; that shows an empty bubble and feels awkward Cancel, diff --git a/src/web/common/components/ContextMenu/DeleteEdgeMenuItem.tsx b/src/web/common/components/ContextMenu/DeleteEdgeMenuItem.tsx index a237b37b..c9d387d3 100644 --- a/src/web/common/components/ContextMenu/DeleteEdgeMenuItem.tsx +++ b/src/web/common/components/ContextMenu/DeleteEdgeMenuItem.tsx @@ -2,6 +2,7 @@ import { justificationRelationNames } from "@/common/edge"; import { ContextMenuItem } from "@/web/common/components/ContextMenu/CloseOnClickMenuItem"; import { useSessionUser } from "@/web/common/hooks"; import { deleteEdge } from "@/web/topic/store/createDeleteActions"; +import { useIsTableEdge } from "@/web/topic/store/edgeHooks"; import { useUserCanEditTopicData } from "@/web/topic/store/userHooks"; import { Edge } from "@/web/topic/utils/graph"; @@ -9,9 +10,13 @@ export const DeleteEdgeMenuItem = ({ edge }: { edge: Edge }) => { const { sessionUser } = useSessionUser(); const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username); + const isTableEdge = useIsTableEdge(edge.id); + if (!userCanEditTopicData) return <>; // doesn't make sense to delete justification edges because they're a tree not a graph - just delete the nodes if (justificationRelationNames.includes(edge.label)) return <>; + // don't allow modifying edges that are part of the table, because they should always exist as long as their nodes do + if (isTableEdge) return <>; return deleteEdge(edge.id)}>Delete edge; }; diff --git a/src/web/common/components/ContextMenu/ViewContextMenuItem.tsx b/src/web/common/components/ContextMenu/ViewContextMenuItem.tsx new file mode 100644 index 00000000..95a8c9ee --- /dev/null +++ b/src/web/common/components/ContextMenu/ViewContextMenuItem.tsx @@ -0,0 +1,12 @@ +import { ContextMenuItem } from "@/web/common/components/ContextMenu/CloseOnClickMenuItem"; +import { GraphPart, isNode } from "@/web/topic/utils/graph"; +import { contextMethods } from "@/web/topic/utils/partContext"; + +export const ViewContextMenuItem = ({ graphPart }: { graphPart: GraphPart }) => { + const partType = isNode(graphPart) ? graphPart.type : graphPart.label; + const viewContext = contextMethods[partType]?.viewContext; + + if (!viewContext) return <>; + + return viewContext(graphPart.id)}>View context; +}; diff --git a/src/web/common/components/ContextMenu/ViewDetailsMenuItem.tsx b/src/web/common/components/ContextMenu/ViewDetailsMenuItem.tsx new file mode 100644 index 00000000..04741623 --- /dev/null +++ b/src/web/common/components/ContextMenu/ViewDetailsMenuItem.tsx @@ -0,0 +1,17 @@ +import { ContextMenuItem } from "@/web/common/components/ContextMenu/CloseOnClickMenuItem"; +import { emitter } from "@/web/common/event"; +import { GraphPart } from "@/web/topic/utils/graph"; +import { setSelected } from "@/web/view/currentViewStore/store"; + +export const ViewDetailsMenuItem = ({ graphPart }: { graphPart: GraphPart }) => { + return ( + { + setSelected(graphPart.id); + emitter.emit("viewTopicDetails"); + }} + > + View details + + ); +}; diff --git a/src/web/common/components/ContextMenu/ViewTableMenuItem.tsx b/src/web/common/components/ContextMenu/ViewTableMenuItem.tsx new file mode 100644 index 00000000..78f55562 --- /dev/null +++ b/src/web/common/components/ContextMenu/ViewTableMenuItem.tsx @@ -0,0 +1,11 @@ +import { ContextMenuItem } from "@/web/common/components/ContextMenu/CloseOnClickMenuItem"; +import { Node } from "@/web/topic/utils/graph"; +import { viewCriteriaTable } from "@/web/view/currentViewStore/filter"; + +export const ViewTableMenuItem = ({ node }: { node: Node }) => { + return ( + viewCriteriaTable(node.id)}> + View criteria table + + ); +}; diff --git a/src/web/topic/components/CriteriaTable/EdgeCell.tsx b/src/web/topic/components/CriteriaTable/EdgeCell.tsx index e3487ffc..73be6ae5 100644 --- a/src/web/topic/components/CriteriaTable/EdgeCell.tsx +++ b/src/web/topic/components/CriteriaTable/EdgeCell.tsx @@ -1,10 +1,14 @@ +import { openContextMenu } from "@/web/common/store/contextMenuActions"; import { CommonIndicators } from "@/web/topic/components/Indicator/CommonIndicators"; import { ContentIndicators } from "@/web/topic/components/Indicator/ContentIndicators"; import { Edge } from "@/web/topic/utils/graph"; export const EdgeCell = ({ edge }: { edge: Edge }) => { return ( -
+
openContextMenu(event, { edge })} + > { /> ); + /** + * Allow edges to be more-easily hovered/clicked, based on a wider width than what is visibly drawn. + * Taken from react flow's implementation https://github.com/xyflow/xyflow/blob/616d2665235447e0280368228ac64b987afecba0/packages/react/src/components/Edges/BaseEdge.tsx#L35-L43 + */ + const hiddenInteractivePath = ( + setSelected(edge.id)} + /> + ); + const labelText = edge.data.customLabel ?? lowerCase(edge.label); const label = ( @@ -165,6 +180,7 @@ export const ScoreEdge = ({ inReactFlow, ...flowEdge }: EdgeProps & Props) => { {/* shouldn't need an svg marker def per edge, but it's easiest to just put here */} {svgMarkerDef(inReactFlow, spotlight)} {path} + {hiddenInteractivePath} {/* see for example usage https://reactflow.dev/docs/api/edges/edge-label-renderer/ */} {label} diff --git a/src/web/topic/components/Node/NodeHandle.styles.tsx b/src/web/topic/components/Node/NodeHandle.styles.tsx deleted file mode 100644 index e2eb88ad..00000000 --- a/src/web/topic/components/Node/NodeHandle.styles.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { css } from "@emotion/react"; -import styled from "@emotion/styled"; -import { Handle } from "reactflow"; - -interface Props { - hasHiddenComponents: boolean; -} - -const options = { - shouldForwardProp: (prop: string) => !["hasHiddenComponents"].includes(prop), -}; - -export const StyledHandle = styled(Handle, options)` - // two selectors to override react-flow's styles - &.react-flow__handle { - width: 10px; - height: 10px; - - ${({ theme, hasHiddenComponents }) => { - if (hasHiddenComponents) { - return css` - background-color: ${theme.palette.info.main}; - `; - } - }} - } -`; diff --git a/src/web/topic/components/Node/NodeHandle.tsx b/src/web/topic/components/Node/NodeHandle.tsx index 7950d798..c03e7e05 100644 --- a/src/web/topic/components/Node/NodeHandle.tsx +++ b/src/web/topic/components/Node/NodeHandle.tsx @@ -1,12 +1,13 @@ import { Visibility } from "@mui/icons-material"; import { IconButton, Tooltip, Typography } from "@mui/material"; import { ReactNode, memo } from "react"; -import { Position } from "reactflow"; +import { Handle, Position } from "reactflow"; import { nodeTypes } from "@/common/node"; -import { StyledHandle } from "@/web/topic/components/Node/NodeHandle.styles"; +import { useSessionUser } from "@/web/common/hooks"; import { useHiddenNodes } from "@/web/topic/hooks/flowHooks"; import { useNeighborsInDirection } from "@/web/topic/store/nodeHooks"; +import { useUserCanEditTopicData } from "@/web/topic/store/userHooks"; import { Node, RelationDirection } from "@/web/topic/utils/graph"; import { Orientation } from "@/web/topic/utils/layout"; import { nodeDecorations } from "@/web/topic/utils/node"; @@ -33,6 +34,9 @@ interface Props { } const NodeHandleBase = ({ node, direction, orientation }: Props) => { + const { sessionUser } = useSessionUser(); + const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username); + const neighborsInDirection = useNeighborsInDirection(node.id, direction); const hiddenNeighbors = useHiddenNodes(neighborsInDirection); @@ -42,6 +46,9 @@ const NodeHandleBase = ({ node, direction, orientation }: Props) => { return diff; }); + const hasHiddenNeighbors = sortedHiddenNeighbors.length > 0; + const showHandle = userCanEditTopicData || hasHiddenNeighbors; + const type = direction === "parent" ? "target" : "source"; const position = @@ -56,7 +63,7 @@ const NodeHandleBase = ({ node, direction, orientation }: Props) => { return ( 0 ? ( + hasHiddenNeighbors ? (
{sortedHiddenNeighbors.map((neighbor) => ( { } disableFocusListener > - 0} + className={ + "size-[10px]" + + (!showHandle ? " invisible" : "") + + (hasHiddenNeighbors ? " bg-info-main" : "") + } /> ); diff --git a/src/web/topic/components/TopicWorkspace/WorkspaceToolbar.tsx b/src/web/topic/components/TopicWorkspace/WorkspaceToolbar.tsx index 406b4761..9889967b 100644 --- a/src/web/topic/components/TopicWorkspace/WorkspaceToolbar.tsx +++ b/src/web/topic/components/TopicWorkspace/WorkspaceToolbar.tsx @@ -19,6 +19,7 @@ import { useSessionUser } from "@/web/common/hooks"; import { HelpMenu } from "@/web/topic/components/TopicWorkspace/HelpMenu"; import { MoreActionsDrawer } from "@/web/topic/components/TopicWorkspace/MoreActionsDrawer"; import { deleteGraphPart } from "@/web/topic/store/createDeleteActions"; +import { useIsTableEdge } from "@/web/topic/store/edgeHooks"; import { useOnPlayground } from "@/web/topic/store/topicHooks"; import { useUserCanEditTopicData } from "@/web/topic/store/userHooks"; import { redo, undo } from "@/web/topic/store/utilActions"; @@ -44,15 +45,18 @@ import { export const WorkspaceToolbar = () => { const { sessionUser } = useSessionUser(); const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username); + const onPlayground = useOnPlayground(); const [canUndo, canRedo] = useTemporalHooks(); const [canGoBack, canGoForward] = useCanGoBackForward(); + const isComparingPerspectives = useIsComparingPerspectives(); const flashlightMode = useFlashlightMode(); const readonlyMode = useReadonlyMode(); const [hasErrored, setHasErrored] = useState(false); const selectedGraphPart = useSelectedGraphPart(); + const partIsTableEdge = useIsTableEdge(selectedGraphPart?.id ?? ""); const [isMoreActionsDrawerOpen, setIsMoreActionsDrawerOpen] = useState(false); const [helpAnchorEl, setHelpAnchorEl] = useState(null); @@ -124,7 +128,8 @@ export const WorkspaceToolbar = () => { deleteGraphPart(selectedGraphPart); } }} - disabled={!selectedGraphPart} + // don't allow modifying edges that are part of the table, because they should always exist as long as their nodes do + disabled={!selectedGraphPart || partIsTableEdge} className="hidden sm:flex" > diff --git a/src/web/topic/store/edgeHooks.ts b/src/web/topic/store/edgeHooks.ts index 0511472b..2fd886c8 100644 --- a/src/web/topic/store/edgeHooks.ts +++ b/src/web/topic/store/edgeHooks.ts @@ -17,3 +17,17 @@ export const useIsNodeSelected = (edgeId: string) => { return useIsAnyGraphPartSelected(neighborNodes.map((node) => node.id)); }; + +export const useIsTableEdge = (edgeId: string) => { + return useTopicStore((state) => { + try { + const edge = findEdgeOrThrow(edgeId, state.edges); + if (edge.label !== "fulfills") return false; + + const [parentNode, childNode] = nodes(edge, state.nodes); + return parentNode.type === "criterion" && childNode.type === "solution"; + } catch { + return false; + } + }); +}; diff --git a/src/web/topic/utils/edge.ts b/src/web/topic/utils/edge.ts index 340e8f5f..9b080b27 100644 --- a/src/web/topic/utils/edge.ts +++ b/src/web/topic/utils/edge.ts @@ -246,7 +246,7 @@ export const childNode = (edge: Edge, nodes: Node[]) => { return findNodeOrThrow(edge.target, nodes); }; -export const nodes = (edge: Edge, nodes: Node[]) => { +export const nodes = (edge: Edge, nodes: Node[]): [Node, Node] => { return [parentNode(edge, nodes), childNode(edge, nodes)]; };