From 2561b8cac52a3c2939291ac506dc9dd7516fcbce Mon Sep 17 00:00:00 2001 From: Adrian Vogelsgesang Date: Sun, 20 Oct 2024 02:16:13 +0200 Subject: [PATCH] Display an editable graph title (#125) --- query-graphs/src/ui/QueryGraph.tsx | 8 ++++-- standalone-app/src/QueryGraphsApp.tsx | 30 +++++++------------- standalone-app/src/TreeLabel.css | 11 +++++++ standalone-app/src/TreeLabel.tsx | 20 +++++++++++++ standalone-app/src/browserUrlHooks.ts | 41 ++++++++++++++++----------- standalone-app/tsconfig.json | 2 +- 6 files changed, 72 insertions(+), 40 deletions(-) create mode 100644 standalone-app/src/TreeLabel.css create mode 100644 standalone-app/src/TreeLabel.tsx diff --git a/query-graphs/src/ui/QueryGraph.tsx b/query-graphs/src/ui/QueryGraph.tsx index 8fdb9dec..4a26d5eb 100644 --- a/query-graphs/src/ui/QueryGraph.tsx +++ b/query-graphs/src/ui/QueryGraph.tsx @@ -3,13 +3,14 @@ import "reactflow/dist/base.css"; import {layoutTree} from "./tree-layout"; import {TreeDescription, TreeNode, allChildren, visitTreeNodes} from "../tree-description"; -import {useMemo, useEffect, useRef} from "react"; +import {useMemo, useEffect, useRef, ReactNode} from "react"; import {QueryNode} from "./QueryNode"; -import "./QueryGraph.css"; import {useGraphRenderingStore} from "./store"; +import "./QueryGraph.css"; interface QueryGraphProps { treeDescription: TreeDescription; + children: ReactNode | ReactNode[]; } function minimapNodeColor(n: Node): string { @@ -22,7 +23,7 @@ const nodeTypes = { querynode: QueryNode, }; -function QueryGraphInternal({treeDescription}: QueryGraphProps) { +function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { // Assign ids to all nodes const nodeIdMapping = useMemo(() => { let nextId = 0; @@ -92,6 +93,7 @@ function QueryGraphInternal({treeDescription}: QueryGraphProps) { nodesFocusable={false} className={"query-graph"} > + {...Array.isArray(children) ? children : [children]} diff --git a/standalone-app/src/QueryGraphsApp.tsx b/standalone-app/src/QueryGraphsApp.tsx index 47c9778f..cb82f7f0 100644 --- a/standalone-app/src/QueryGraphsApp.tsx +++ b/standalone-app/src/QueryGraphsApp.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useState} from "react"; +import {useCallback, useEffect, useState} from "react"; import {useBrowserUrl, useUrlParam} from "./browserUrlHooks"; import {FileOpener, FileOpenerData, useLoadStateController} from "./FileOpener"; import {QueryGraph} from "@tableau/query-graphs/lib/ui/QueryGraph"; @@ -6,6 +6,7 @@ import {TreeDescription} from "@tableau/query-graphs/lib/tree-description"; import {loadPlan} from "./tree-loader"; import {tryCreateLocalStorageUrl, isLocalStorageURL, loadLocalStorageURL} from "./LocalStorageUrl"; import {assert} from "./assert"; +import {TreeLabel} from "./TreeLabel"; export function QueryGraphsApp() { const loadStateController = useLoadStateController(); @@ -15,7 +16,7 @@ export function QueryGraphsApp() { // We store the currently opened tree in a URL parameter. // Thereby, we automatically integrate with the browser's history. const [treeUrl, setTreeUrl] = useUrlParam(browserUrl, "file"); - const [treeTitle, setTreeTitle] = useUrlParam(browserUrl, "title"); + const [treeTitle, setTreeTitle] = useUrlParam(browserUrl, "title", true); const [uploadServer] = useUrlParam(browserUrl, "uploadServer"); // Callback for the file opener const openPickedData = async (data: FileOpenerData): Promise => { @@ -49,7 +50,7 @@ export function QueryGraphsApp() { if (data.fileName) { title = data.fileName; } else { - title = "query plan"; + title = new Date().toLocaleString(); } // Update the tree URL such that link sharing works. setTreeTitle(title); @@ -116,24 +117,13 @@ export function QueryGraphsApp() { } }, []); - // We annotate the tree with the `title` - const annotatedTree = useMemo(() => { - if (tree === undefined) return undefined; - const newTree = { - ...tree, - }; - if (newTree.properties === undefined) { - newTree.properties = new Map(); - } - if (treeTitle) { - newTree.properties.set("title", treeTitle); - } - return newTree; - }, [tree, treeTitle]); - - if (!annotatedTree) { + if (!tree) { return ; } else { - return ; + return ( + + + + ); } } diff --git a/standalone-app/src/TreeLabel.css b/standalone-app/src/TreeLabel.css new file mode 100644 index 00000000..b9aaca4f --- /dev/null +++ b/standalone-app/src/TreeLabel.css @@ -0,0 +1,11 @@ +.graph-title { + font-size: 1.2em; + font-weight: bold; + field-sizing: content; + min-width: 10em; + border: none; +} + +.graph-title:hover { + background: #ffeded; +} \ No newline at end of file diff --git a/standalone-app/src/TreeLabel.tsx b/standalone-app/src/TreeLabel.tsx new file mode 100644 index 00000000..99c91ffe --- /dev/null +++ b/standalone-app/src/TreeLabel.tsx @@ -0,0 +1,20 @@ +import "./TreeLabel.css"; + +export interface TreeLabelProps { + title: string; + setTitle?: (v: string) => void; +} + +export function TreeLabel({title, setTitle}: TreeLabelProps) { + return ( +
+ (setTitle ? setTitle(e.target.value) : undefined)} + /> +
+ ); +} diff --git a/standalone-app/src/browserUrlHooks.ts b/standalone-app/src/browserUrlHooks.ts index b83d3052..79b58463 100644 --- a/standalone-app/src/browserUrlHooks.ts +++ b/standalone-app/src/browserUrlHooks.ts @@ -3,28 +3,42 @@ import type React from "react"; import {useCallback, useEffect, useState} from "react"; -type URLState = [URL, React.Dispatch>]; +type URLState = [URL, (value: React.SetStateAction, noHistoryEntry?: boolean) => void]; export function useBrowserUrl(): URLState { - const [url, setUrl] = useState(() => new URL(window.location.toString())); + const [url, setUrlInternal] = useState(() => new URL(window.location.toString())); useEffect(() => { const listener = (_e: PopStateEvent) => { - setUrl(new URL(window.location.toString())); + setUrlInternal(new URL(window.location.toString())); }; window.addEventListener("popstate", listener); return () => window.removeEventListener("popstate", listener); - }, [setUrl]); - useEffect(() => { - if (url.toString() != new URL(window.location.toString()).toString()) { - window.history.pushState(null, "", url); - } - }, [url]); + }, [setUrlInternal]); + const setUrl = useCallback( + (action: React.SetStateAction, noHistoryEntry?: boolean) => { + let newUrl: URL; + if (action instanceof Function) newUrl = action(url); + else newUrl = action; + setUrlInternal(newUrl); + // Don't change the URL if the parameter didn't change. + // This is important to ensure the history stays intact. + if (newUrl.toString() != new URL(window.location.toString()).toString()) { + if (noHistoryEntry) { + window.history.replaceState(null, "", newUrl); + } else { + window.history.pushState(null, "", newUrl); + } + } + }, + [url, setUrlInternal], + ); return [url, setUrl]; } export function useUrlParam( urlState: URLState, key: string, + noHistoryEntry?: boolean, ): [string | undefined, React.Dispatch>] { const [url, setUrl] = urlState; const v = url.searchParams.get(key) ?? undefined; @@ -35,11 +49,6 @@ export function useUrlParam( let newValue: string | undefined; if (action instanceof Function) newValue = action(currentValue); else newValue = action; - if (newValue === currentValue) { - // Don't change the URL if the parameter didn't change. - // This is important to ensure the history stays intact. - return url; - } const newURL = new URL(url); if (newValue === undefined) { newURL.searchParams.delete(key); @@ -47,9 +56,9 @@ export function useUrlParam( newURL.searchParams.set(key, newValue); } return newURL; - }); + }, noHistoryEntry); }, - [setUrl, key], + [setUrl, key, noHistoryEntry], ); return [v, set]; } diff --git a/standalone-app/tsconfig.json b/standalone-app/tsconfig.json index 2f395a0d..7dd33f65 100644 --- a/standalone-app/tsconfig.json +++ b/standalone-app/tsconfig.json @@ -15,4 +15,4 @@ "ts-node": { "compilerOptions": { "module": "commonjs" } } - } \ No newline at end of file + }