diff --git a/src/web/comment/store/commentStore.ts b/src/web/comment/store/commentStore.ts index bc5870bf..4e70ac93 100644 --- a/src/web/comment/store/commentStore.ts +++ b/src/web/comment/store/commentStore.ts @@ -240,5 +240,5 @@ export const viewComment = (commentId: string) => { const commentParentGraphPartId = threadStarterComment.parentType === "topic" ? null : threadStarterComment.parentId; setSelected(commentParentGraphPartId); - emitter.emit("viewTopicDetails"); + emitter.emit("viewComments"); }; diff --git a/src/web/common/components/ContextMenu/ViewDetailsMenuItem.tsx b/src/web/common/components/ContextMenu/ViewDetailsMenuItem.tsx index 04741623..9fb41b40 100644 --- a/src/web/common/components/ContextMenu/ViewDetailsMenuItem.tsx +++ b/src/web/common/components/ContextMenu/ViewDetailsMenuItem.tsx @@ -8,7 +8,7 @@ export const ViewDetailsMenuItem = ({ graphPart }: { graphPart: GraphPart }) => { setSelected(graphPart.id); - emitter.emit("viewTopicDetails"); + emitter.emit("viewBasics"); }} > View details diff --git a/src/web/common/event.ts b/src/web/common/event.ts index 393528d5..7f8a4bcd 100644 --- a/src/web/common/event.ts +++ b/src/web/common/event.ts @@ -10,7 +10,10 @@ interface Events { changedDiagramFilter: () => void; changedLayoutConfig: () => void; changedView: (newView: ViewState) => void; - viewTopicDetails: () => void; + viewBasics: () => void; + viewJustification: () => void; + viewResearch: () => void; + viewComments: () => void; partSelected: (partId: string | null) => void; } diff --git a/src/web/topic/components/Indicator/CommentIndicator.tsx b/src/web/topic/components/Indicator/CommentIndicator.tsx index 7e02e933..b4a08755 100644 --- a/src/web/topic/components/Indicator/CommentIndicator.tsx +++ b/src/web/topic/components/Indicator/CommentIndicator.tsx @@ -21,7 +21,7 @@ export const CommentIndicator = ({ graphPartId, graphPartType, partColor }: Prop const onClick = useCallback(() => { setSelected(graphPartId); - emitter.emit("viewTopicDetails"); + emitter.emit("viewComments"); }, [graphPartId]); if (commentCount === 0) return <>; diff --git a/src/web/topic/components/Indicator/ContentIndicators.tsx b/src/web/topic/components/Indicator/ContentIndicators.tsx index ecdbfaf2..3f1b5fe8 100644 --- a/src/web/topic/components/Indicator/ContentIndicators.tsx +++ b/src/web/topic/components/Indicator/ContentIndicators.tsx @@ -17,9 +17,9 @@ interface Props { const ContentIndicatorsBase = ({ graphPartId, graphPartType, color, className }: Props) => { return ( + - ); diff --git a/src/web/topic/components/Indicator/DetailsIndicator.tsx b/src/web/topic/components/Indicator/DetailsIndicator.tsx index 88aa75ee..2414eca3 100644 --- a/src/web/topic/components/Indicator/DetailsIndicator.tsx +++ b/src/web/topic/components/Indicator/DetailsIndicator.tsx @@ -15,7 +15,7 @@ export const DetailsIndicator = ({ graphPartId, notes }: Props) => { const onClick = useCallback(() => { setSelected(graphPartId); - emitter.emit("viewTopicDetails"); + emitter.emit("viewBasics"); }, [graphPartId]); return ; diff --git a/src/web/topic/components/Indicator/FoundResearchIndicator.tsx b/src/web/topic/components/Indicator/FoundResearchIndicator.tsx index 48078bed..5354332b 100644 --- a/src/web/topic/components/Indicator/FoundResearchIndicator.tsx +++ b/src/web/topic/components/Indicator/FoundResearchIndicator.tsx @@ -22,7 +22,7 @@ export const FoundResearchIndicator = ({ graphPartId, partColor }: Props) => { const onClick = useCallback(() => { setSelected(graphPartId); - emitter.emit("viewTopicDetails"); + emitter.emit("viewResearch"); }, [graphPartId]); if (foundResearchNodes.length === 0) return <>; diff --git a/src/web/topic/components/Indicator/JustificationIndicator.tsx b/src/web/topic/components/Indicator/JustificationIndicator.tsx index 7aade881..52ef06eb 100644 --- a/src/web/topic/components/Indicator/JustificationIndicator.tsx +++ b/src/web/topic/components/Indicator/JustificationIndicator.tsx @@ -22,7 +22,7 @@ export const JustificationIndicator = ({ graphPartId, partColor }: Props) => { const onClick = useCallback(() => { setSelected(graphPartId); - emitter.emit("viewTopicDetails"); + emitter.emit("viewJustification"); }, [graphPartId]); if (justificationNodes.length === 0) return <>; diff --git a/src/web/topic/components/Indicator/QuestionIndicator.tsx b/src/web/topic/components/Indicator/QuestionIndicator.tsx index 9e9e3519..a505f838 100644 --- a/src/web/topic/components/Indicator/QuestionIndicator.tsx +++ b/src/web/topic/components/Indicator/QuestionIndicator.tsx @@ -21,7 +21,7 @@ export const QuestionIndicator = ({ graphPartId, partColor }: Props) => { const onClick = useCallback(() => { setSelected(graphPartId); - emitter.emit("viewTopicDetails"); + emitter.emit("viewResearch"); }, [graphPartId]); if (questions.length === 0) return <>; diff --git a/src/web/topic/components/TopicPane/CommentSection.tsx b/src/web/topic/components/TopicPane/CommentSection.tsx index 544e4b1c..31fcc2a2 100644 --- a/src/web/topic/components/TopicPane/CommentSection.tsx +++ b/src/web/topic/components/TopicPane/CommentSection.tsx @@ -1,5 +1,4 @@ -import { ChatBubble } from "@mui/icons-material"; -import { Link, ListItem, ListItemIcon, ListItemText } from "@mui/material"; +import { Link, ListItem } from "@mui/material"; import { CommentParentType } from "@/common/comment"; import { Draft } from "@/web/comment/components/Draft"; @@ -31,24 +30,22 @@ export const CommentSection = ({ parentId, parentType }: Props) => { return ( <> - - - - - - {resolvedCount > 0 && ( + {resolvedCount > 0 && ( + // extra space to the left feels a little awkward, but this can't be on the same line as the header + // because the header can be within a list of tabs + { toggleShowResolvedComments(!showResolved); - e.preventDefault(); // without this, page refreshes - not sure why, since component is as button, not an anchor + e.preventDefault(); // without this, page refreshes - not sure why, since component is a button, not an anchor }} > {showResolved ? "Hide resolved" : "Show resolved"} - )} - + + )}
diff --git a/src/web/topic/components/TopicPane/DetailsBasicsSection.tsx b/src/web/topic/components/TopicPane/DetailsBasicsSection.tsx new file mode 100644 index 00000000..129e3998 --- /dev/null +++ b/src/web/topic/components/TopicPane/DetailsBasicsSection.tsx @@ -0,0 +1,82 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { ListItem, TextField } from "@mui/material"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { nodeSchema } from "@/common/node"; +import { useSessionUser } from "@/web/common/hooks"; +import { AnswerDetails } from "@/web/topic/components/TopicPane/AnswerDetails"; +import { FactDetails } from "@/web/topic/components/TopicPane/FactDetails"; +import { QuestionDetails } from "@/web/topic/components/TopicPane/QuestionDetails"; +import { SourceDetails } from "@/web/topic/components/TopicPane/SourceDetails"; +import { setGraphPartNotes } from "@/web/topic/store/actions"; +import { useUserCanEditTopicData } from "@/web/topic/store/userHooks"; +import { GraphPart, isNodeType } from "@/web/topic/utils/graph"; + +const formSchema = z.object({ + // same restrictions as edge, so we should be fine reusing node's schema + notes: nodeSchema.shape.notes, +}); +type FormData = z.infer; + +interface Props { + graphPart: GraphPart; +} + +export const DetailsBasicsSection = ({ graphPart }: Props) => { + const { sessionUser } = useSessionUser(); + const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username); + + const { + register, + reset, + handleSubmit, + formState: { errors }, + } = useForm({ + mode: "onBlur", + reValidateMode: "onBlur", + resolver: zodResolver(formSchema), + defaultValues: { + notes: graphPart.data.notes, + }, + }); + + useEffect(() => { + // when notes changes from outside of form (e.g. undo/redo), make sure form is updated + reset({ notes: graphPart.data.notes }); + }, [graphPart.data.notes, reset]); + + return ( +
{ + void handleSubmit((data) => { + if (graphPart.data.notes === data.notes) return; + setGraphPartNotes(graphPart, data.notes); + })(event); + }} + > + + + + + {/* Potentially these could be in another tab like "specific for this node type"...? */} + {/* but these seem low-use anyway and more effort to organize optimally, so we'll just do this for now. */} + {isNodeType(graphPart, "question") && } + {isNodeType(graphPart, "answer") && } + {isNodeType(graphPart, "fact") && } + {isNodeType(graphPart, "source") && } + + ); +}; diff --git a/src/web/topic/components/TopicPane/DetailsJustificationSection.tsx b/src/web/topic/components/TopicPane/DetailsJustificationSection.tsx index 1ef6cb37..d344062b 100644 --- a/src/web/topic/components/TopicPane/DetailsJustificationSection.tsx +++ b/src/web/topic/components/TopicPane/DetailsJustificationSection.tsx @@ -1,5 +1,4 @@ -import { ThumbsUpDown } from "@mui/icons-material"; -import { Box, ListItem, ListItemIcon, ListItemText, Stack, Typography } from "@mui/material"; +import { Box, Stack, Typography } from "@mui/material"; import { justificationNodeTypes } from "@/common/node"; import { JustificationTreeIndicator } from "@/web/topic/components/Indicator/JustificationTreeIndicator"; @@ -23,13 +22,6 @@ export const DetailsJustificationSection = ({ graphPart }: Props) => { return ( <> - - - - - - - {/* spacing is the amount that centers the add buttons above the columns */} { return ( <> - - - - - - - {/* spacing is the amount that centers the add buttons above the columns */} ; +export type DetailsTab = "Basics" | "Justification" | "Research" | "Comments"; interface Props { graphPart: GraphPart; + /** + * This is hoisted to parent so that it's preserved when a part becomes deselected & reselected. + * + * - Alternative 1: keep `GraphPartDetails` always rendered; but performance, and `viewTab` event + * handling would need to move around. + * - Alternative 2: use a store for this state; but seems like overkill? + */ + selectedTab: DetailsTab; + setSelectedTab: (tab: DetailsTab) => void; } -export const GraphPartDetails = ({ graphPart }: Props) => { - const { sessionUser } = useSessionUser(); - const userCanEditTopicData = useUserCanEditTopicData(sessionUser?.username); +export const GraphPartDetails = ({ graphPart, selectedTab, setSelectedTab }: Props) => { + const expandDetailsTabs = useExpandDetailsTabs(); - const { - register, - reset, - handleSubmit, - formState: { errors }, - } = useForm({ - mode: "onBlur", - reValidateMode: "onBlur", - resolver: zodResolver(formSchema), - defaultValues: { - notes: graphPart.data.notes, - }, - }); + const partIsNode = isNode(graphPart); - useEffect(() => { - // when notes changes from outside of form (e.g. undo/redo), make sure form is updated - reset({ notes: graphPart.data.notes }); - }, [graphPart.data.notes, reset]); + // Ideally we could exactly reuse the indicator logic here, rather than duplicating, but not sure + // a good way to do that, so we're just duplicating the logic for now. + // Don't want to use the exact indicators, because in the pane, it seems worse to show partial icons e.g. ThumbsUp vs ThumbsDown. + // Maybe could extract logic from the specific indicators, but that seems also like a decent amount of extra abstraction. + const { supports, critiques } = useTopLevelJustification(graphPart.id); + const { questions, facts, sources } = useResearchNodes(graphPart.id); + const showResolved = useShowResolvedComments(); + const commentCount = useCommentCount(graphPart.id, partIsNode ? "node" : "edge", showResolved); - const partIsNode = isNode(graphPart); - const GraphPartIcon = partIsNode ? nodeDecorations[graphPart.type].NodeIcon : Timeline; - const headerText = partIsNode - ? `${nodeDecorations[graphPart.type].title} Node` - : `"${lowerCase(graphPart.label)}" Edge`; + const indicateBasics = graphPart.data.notes.length > 0; + const indicateJustification = [...supports, ...critiques].length > 0; + const indicateResearch = [...questions, ...facts, ...sources].length > 0; + const indicateComments = commentCount > 0; return ( -
{ - void handleSubmit((data) => { - if (graphPart.data.notes === data.notes) return; - setGraphPartNotes(graphPart, data.notes); - })(event); - }} - > - -
+ +
+ {partIsNode ? ( + // z-index to ensure hanging node indicators don't fall behind the next section's empty background + + ) : ( + + )} +
+ + {/* mt-2 to match distance from Tabs look to graph part */} + + + {!expandDetailsTabs ? ( + <> + + setSelectedTab(value)} + centered + className="px-2" + > + : } + value="Basics" + title="Basics" + aria-label="Basics" + /> + : } + value="Justification" + title="Justification" + aria-label="Justification" + /> + {partIsNode && ( + : } + value="Research" + title="Research" + aria-label="Research" + /> + )} + : } + value="Comments" + title="Comments" + aria-label="Comments" + /> + + + + + + Basics + + + + + + + + Justification + + + + + {partIsNode && ( + + + + Research + + + + + )} + + + + Comments + + + + + + + ) : ( + <> - +
- + + - {partIsNode ? ( - // z-index to ensure hanging node indicators don't fall behind the next section's empty background - - ) : ( - - )} - - - + + + + + + -
+ + + {/* prevent adding research nodes to edges; not 100% sure that we want to restrict this, but if it continues to seem good, this section can accept node instead of graphPart */} + {partIsNode && ( + <> + + + + + + + + + + )} - {isNode(graphPart) && researchNodeTypes.includes(graphPart.type) && ( - )} - - {isNodeType(graphPart, "question") && } - {isNodeType(graphPart, "answer") && } - {isNodeType(graphPart, "fact") && } - {isNodeType(graphPart, "source") && } - - - - - - {/* prevent adding research nodes to edges; not 100% sure that we want to restrict this, but if it continues to seem good, this section can accept node instead of graphPart */} - {partIsNode && ( - <> - - - - )} - - - - -
-
+ + + + + + + + + )} + ); }; diff --git a/src/web/topic/components/TopicPane/TopicPane.tsx b/src/web/topic/components/TopicPane/TopicPane.tsx index 95fb5be4..83f9d1d3 100644 --- a/src/web/topic/components/TopicPane/TopicPane.tsx +++ b/src/web/topic/components/TopicPane/TopicPane.tsx @@ -11,7 +11,7 @@ import { memo, useEffect, useState } from "react"; import { deepIsEqual } from "@/common/utils"; import { emitter } from "@/web/common/event"; -import { GraphPartDetails } from "@/web/topic/components/TopicPane/GraphPartDetails"; +import { DetailsTab, GraphPartDetails } from "@/web/topic/components/TopicPane/GraphPartDetails"; import { TopicDetails } from "@/web/topic/components/TopicPane/TopicDetails"; import { Anchor, @@ -38,13 +38,35 @@ interface Props { const TopicPaneBase = ({ anchor, tabs }: Props) => { const [isOpen, setIsOpen] = useState(true); const [selectedTab, setSelectedTab] = useState(tabs[0]); + + const [selectedPartDetailsTab, setSelectedPartDetailsTab] = useState("Basics"); + const selectedGraphPart = useSelectedGraphPart(); useEffect(() => { if (!tabs.includes("Details")) return; - const unbindSelectDetails = emitter.on("viewTopicDetails", () => { + const unbindSelectBasics = emitter.on("viewBasics", () => { + setSelectedTab("Details"); + setSelectedPartDetailsTab("Basics"); + setIsOpen(true); + }); + + const unbindSelectJustification = emitter.on("viewJustification", () => { + setSelectedTab("Details"); + setSelectedPartDetailsTab("Justification"); + setIsOpen(true); + }); + + const unbindSelectResearch = emitter.on("viewResearch", () => { + setSelectedTab("Details"); + setSelectedPartDetailsTab("Research"); + setIsOpen(true); + }); + + const unbindSelectComments = emitter.on("viewComments", () => { setSelectedTab("Details"); + setSelectedPartDetailsTab("Comments"); setIsOpen(true); }); @@ -53,7 +75,10 @@ const TopicPaneBase = ({ anchor, tabs }: Props) => { }); return () => { - unbindSelectDetails(); + unbindSelectBasics(); + unbindSelectJustification(); + unbindSelectResearch(); + unbindSelectComments(); unbindSelectedPart(); }; }, [tabs]); @@ -71,7 +96,13 @@ const TopicPaneBase = ({ anchor, tabs }: Props) => { const TabPanelContent = { Details: selectedGraphPart !== null ? ( - + ) : ( ), @@ -83,7 +114,12 @@ const TopicPaneBase = ({ anchor, tabs }: Props) => { - + {tabs.map((tab) => ( diff --git a/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx b/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx index 20fc3927..935920e2 100644 --- a/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx +++ b/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx @@ -16,6 +16,7 @@ import { PowerInput, Route, SsidChart, + UnfoldMore, Upload, WbTwilight, } from "@mui/icons-material"; @@ -70,8 +71,10 @@ import { import { resetView, useFormat } from "@/web/view/currentViewStore/store"; import { resetQuickViews } from "@/web/view/quickViewStore/store"; import { + toggleExpandDetailsTabs, toggleFillNodesWithColor, toggleIndicateWhenNodeForcedToShow, + useExpandDetailsTabs, useFillNodesWithColor, useIndicateWhenNodeForcedToShow, } from "@/web/view/userConfigStore"; @@ -142,6 +145,7 @@ export const MoreActionsDrawer = ({ const fillNodesWithColor = useFillNodesWithColor(); const indicateWhenNodeForcedToShow = useIndicateWhenNodeForcedToShow(); + const expandDetailsTabs = useExpandDetailsTabs(); return ( + toggleExpandDetailsTabs()} + sx={{ borderRadius: "50%", border: "0" }} + > + + diff --git a/src/web/view/userConfigStore.ts b/src/web/view/userConfigStore.ts index 98ef0751..249e39b6 100644 --- a/src/web/view/userConfigStore.ts +++ b/src/web/view/userConfigStore.ts @@ -5,12 +5,14 @@ interface UserConfigStoreState { showIndicators: boolean; fillNodesWithColor: boolean; indicateWhenNodeForcedToShow: boolean; + expandDetailsTabs: boolean; } const initialState: UserConfigStoreState = { showIndicators: false, fillNodesWithColor: false, indicateWhenNodeForcedToShow: false, + expandDetailsTabs: false, }; const useUserConfigStore = create()( @@ -32,6 +34,10 @@ export const useIndicateWhenNodeForcedToShow = () => { return useUserConfigStore((state) => state.indicateWhenNodeForcedToShow); }; +export const useExpandDetailsTabs = () => { + return useUserConfigStore((state) => state.expandDetailsTabs); +}; + // actions export const toggleShowIndicators = () => { useUserConfigStore.setState((state) => ({ showIndicators: !state.showIndicators })); @@ -44,3 +50,7 @@ export const toggleFillNodesWithColor = (fill: boolean) => { export const toggleIndicateWhenNodeForcedToShow = (indicate: boolean) => { useUserConfigStore.setState({ indicateWhenNodeForcedToShow: indicate }); }; + +export const toggleExpandDetailsTabs = () => { + useUserConfigStore.setState((state) => ({ expandDetailsTabs: !state.expandDetailsTabs })); +};