From e5876cc62fa918a6a84d65d3d569f0327a581754 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 22 Mar 2026 16:24:48 -0400 Subject: [PATCH 1/6] [ENG-1545] Switch positions of node type and node title fields Move Content input above Node Type selector so users can immediately start typing. Support empty Node Type (queries all node types), auto-detect type on selection, and lock type when a node is selected. Co-Authored-By: Claude Opus 4.6 --- apps/roam/src/components/ModifyNodeDialog.tsx | 115 +++++++++++++----- .../utils/registerCommandPaletteCommands.ts | 12 +- 2 files changed, 86 insertions(+), 41 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index dafd67f07..45a68403f 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -109,13 +109,16 @@ const ModifyNodeDialog = ({ : allNodes.filter(excludeDefaultNodes); }, [includeDefaultNodes]); - const [selectedNodeType, setSelectedNodeType] = useState(() => { + const [selectedNodeType, setSelectedNodeType] = useState< + (typeof discourseNodes)[number] | null + >(() => { + if (!nodeType) return null; const node = discourseNodes.find((n) => n.type === nodeType); - return node || discourseNodes[0]; + return node || null; }); const nodeFormat = useMemo(() => { - return selectedNodeType.format || ""; + return selectedNodeType?.format || ""; }, [selectedNodeType]); const referencedNode = useMemo(() => { @@ -160,6 +163,39 @@ const ModifyNodeDialog = ({ if (contentRequestIdRef.current === req && alive) { setOptions((prev) => ({ ...prev, content: results })); } + } else { + // Query all discourse node types in parallel + const allResults = await Promise.all( + discourseNodes.map(async (node) => { + const conditionUid = window.roamAlphaAPI.util.generateUID(); + const results = await fireQuery({ + returnNode: "node", + selections: [], + conditions: [ + { + source: "node", + relation: "is a", + target: node.type, + uid: conditionUid, + type: "clause", + }, + ], + }); + return results.map((r) => ({ + ...r, + _discourseNodeType: node.type, + })); + }), + ); + const seen = new Set(); + const deduped = allResults.flat().filter((r) => { + if (seen.has(r.uid)) return false; + seen.add(r.uid); + return true; + }); + if (contentRequestIdRef.current === req && alive) { + setOptions((prev) => ({ ...prev, content: deduped })); + } } } catch (error) { if (contentRequestIdRef.current === req && alive) { @@ -226,9 +262,20 @@ const ModifyNodeDialog = ({ }; }, [selectedNodeType, referencedNode]); - const setValue = useCallback((r: Result) => { - setContent(r); - }, []); + const setValue = useCallback( + (r: Result) => { + setContent(r); + if (!selectedNodeType && r.uid) { + const detectedType = (r as Record) + ._discourseNodeType as string | undefined; + if (detectedType) { + const nt = discourseNodes.find((n) => n.type === detectedType); + if (nt) setSelectedNodeType(nt); + } + } + }, + [selectedNodeType, discourseNodes], + ); const setReferencedNodeValueCallback = useCallback((r: Result) => { setReferencedNodeValue(r); @@ -304,9 +351,13 @@ const ModifyNodeDialog = ({ const onSubmit = async () => { if (!content.text.trim()) return; + if (!selectedNodeType && !isContentLocked) { + setError("Please select a node type"); + return; + } posthog.capture("Modify Node Dialog: Submit Triggered", { mode, - nodeType: selectedNodeType.type, + nodeType: selectedNodeType?.type, }); try { if (mode === "create") { @@ -326,7 +377,7 @@ const ModifyNodeDialog = ({ await addImageToPage({ pageUid, imageUrl, - configPageUid: selectedNodeType.type, + configPageUid: selectedNodeType!.type, extensionAPI, }); } @@ -373,7 +424,7 @@ const ModifyNodeDialog = ({ } else { formattedTitle = await getNewDiscourseNodeText({ text: content.text.trim(), - nodeType: selectedNodeType.type, + nodeType: selectedNodeType!.type, blockUid: sourceBlockUid, }); } @@ -384,7 +435,7 @@ const ModifyNodeDialog = ({ // Create new discourse node const newPageUid = await createDiscourseNode({ text: formattedTitle, - configPageUid: selectedNodeType.type, + configPageUid: selectedNodeType!.type, extensionAPI, imageUrl, }); @@ -505,6 +556,26 @@ const ModifyNodeDialog = ({ style={{ pointerEvents: "all" }} >
+ {/* Content Input */} +
+ + +
+ {/* Node Type Selector */}
- {/* Content Input */} -
- - -
- {/* Referenced Node Input */} {referencedNode && !isContentLocked && mode === "create" && (
diff --git a/apps/roam/src/utils/registerCommandPaletteCommands.ts b/apps/roam/src/utils/registerCommandPaletteCommands.ts index 6e5dbf61e..2eb143229 100644 --- a/apps/roam/src/utils/registerCommandPaletteCommands.ts +++ b/apps/roam/src/utils/registerCommandPaletteCommands.ts @@ -189,19 +189,9 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { const selectionStart = uid ? getSelectionStartForBlock(uid) : 0; - const defaultNodeType = - getDiscourseNodes().filter(excludeDefaultNodes)[0]?.type; - if (!defaultNodeType) { - renderToast({ - id: "create-discourse-node-command-no-types", - content: "No discourse node types found in settings.", - }); - return; - } - renderModifyNodeDialog({ mode: "create", - nodeType: defaultNodeType, + nodeType: "", initialValue: { text: "", uid: "" }, extensionAPI, onSuccess: async (result) => { From f3d463021f9ca7640bafc1d11a0f93e9a2b56d18 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 27 Mar 2026 16:29:33 -0400 Subject: [PATCH 2/6] lint and remove unnecessary change --- apps/roam/src/components/ModifyNodeDialog.tsx | 2 +- .../roam/src/utils/registerCommandPaletteCommands.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 45a68403f..813155129 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -183,7 +183,7 @@ const ModifyNodeDialog = ({ }); return results.map((r) => ({ ...r, - _discourseNodeType: node.type, + discourseNodeType: node.type, })); }), ); diff --git a/apps/roam/src/utils/registerCommandPaletteCommands.ts b/apps/roam/src/utils/registerCommandPaletteCommands.ts index 2eb143229..6e5dbf61e 100644 --- a/apps/roam/src/utils/registerCommandPaletteCommands.ts +++ b/apps/roam/src/utils/registerCommandPaletteCommands.ts @@ -189,9 +189,19 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { const selectionStart = uid ? getSelectionStartForBlock(uid) : 0; + const defaultNodeType = + getDiscourseNodes().filter(excludeDefaultNodes)[0]?.type; + if (!defaultNodeType) { + renderToast({ + id: "create-discourse-node-command-no-types", + content: "No discourse node types found in settings.", + }); + return; + } + renderModifyNodeDialog({ mode: "create", - nodeType: "", + nodeType: defaultNodeType, initialValue: { text: "", uid: "" }, extensionAPI, onSuccess: async (result) => { From 91cec862cf9ed98b7c592e0cf871ca72403d0cfe Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 27 Mar 2026 16:51:35 -0400 Subject: [PATCH 3/6] small fixes --- apps/roam/src/components/ModifyNodeDialog.tsx | 10 +++++++--- .../roam/src/utils/registerCommandPaletteCommands.ts | 12 +----------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 813155129..37dcfa86a 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -260,17 +260,20 @@ const ModifyNodeDialog = ({ alive = false; refAlive = false; }; - }, [selectedNodeType, referencedNode]); + }, [selectedNodeType, referencedNode, discourseNodes]); const setValue = useCallback( (r: Result) => { setContent(r); if (!selectedNodeType && r.uid) { const detectedType = (r as Record) - ._discourseNodeType as string | undefined; + .discourseNodeType as string | undefined; if (detectedType) { const nt = discourseNodes.find((n) => n.type === detectedType); - if (nt) setSelectedNodeType(nt); + if (nt) { + setSelectedNodeType(nt); + setError(""); + } } } }, @@ -591,6 +594,7 @@ const ModifyNodeDialog = ({ if (nt) { setSelectedNodeType(nt); setReferencedNodeValue({ text: "", uid: "" }); + setError(""); } }} disabled={ diff --git a/apps/roam/src/utils/registerCommandPaletteCommands.ts b/apps/roam/src/utils/registerCommandPaletteCommands.ts index 6e5dbf61e..2eb143229 100644 --- a/apps/roam/src/utils/registerCommandPaletteCommands.ts +++ b/apps/roam/src/utils/registerCommandPaletteCommands.ts @@ -189,19 +189,9 @@ export const registerCommandPaletteCommands = (onloadArgs: OnloadArgs) => { const selectionStart = uid ? getSelectionStartForBlock(uid) : 0; - const defaultNodeType = - getDiscourseNodes().filter(excludeDefaultNodes)[0]?.type; - if (!defaultNodeType) { - renderToast({ - id: "create-discourse-node-command-no-types", - content: "No discourse node types found in settings.", - }); - return; - } - renderModifyNodeDialog({ mode: "create", - nodeType: defaultNodeType, + nodeType: "", initialValue: { text: "", uid: "" }, extensionAPI, onSuccess: async (result) => { From 91e1f57836d8695f3046b2091a7ab8b2c0344385 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 27 Mar 2026 20:47:02 -0400 Subject: [PATCH 4/6] address pr comment --- apps/roam/src/components/ModifyNodeDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 37dcfa86a..16a768cee 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -380,7 +380,7 @@ const ModifyNodeDialog = ({ await addImageToPage({ pageUid, imageUrl, - configPageUid: selectedNodeType!.type, + configPageUid: selectedNodeType?.type || "", extensionAPI, }); } @@ -602,7 +602,7 @@ const ModifyNodeDialog = ({ } popoverProps={{ openOnTargetFocus: false }} className={ - mode === "edit" || disableNodeTypeChange + mode === "edit" || disableNodeTypeChange || isContentLocked ? "cursor-not-allowed opacity-50" : "" } From 62211b30486d9123004a682e887ef33f4228a1d7 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 15 Apr 2026 14:06:45 -0400 Subject: [PATCH 5/6] address PR comments --- apps/roam/src/components/ModifyNodeDialog.tsx | 76 +++++++------------ .../src/utils/getAllDiscourseNodeInstances.ts | 47 ++++++++++++ 2 files changed, 75 insertions(+), 48 deletions(-) create mode 100644 apps/roam/src/utils/getAllDiscourseNodeInstances.ts diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index 16a768cee..a6d81378a 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -1,6 +1,7 @@ import { Button, Classes, + Colors, Dialog, Intent, Label, @@ -22,7 +23,9 @@ import renderOverlay, { import fireQuery from "~/utils/fireQuery"; import getDiscourseNodes, { excludeDefaultNodes, + type DiscourseNode, } from "~/utils/getDiscourseNodes"; +import { getAllDiscourseNodeInstances } from "~/utils/getAllDiscourseNodeInstances"; import FuzzySelectInput from "./FuzzySelectInput"; import { createBlock, updateBlock } from "roamjs-components/writes"; import { @@ -40,7 +43,7 @@ import posthog from "posthog-js"; export type ModifyNodeDialogMode = "create" | "edit"; export type ModifyNodeDialogProps = { mode: ModifyNodeDialogMode; - nodeType: string; + nodeType?: string; initialValue: { text: string; uid: string }; initialReferencedNode?: { text: string; uid: string }; sourceBlockUid?: string; //the block that we started modifying from @@ -110,11 +113,10 @@ const ModifyNodeDialog = ({ }, [includeDefaultNodes]); const [selectedNodeType, setSelectedNodeType] = useState< - (typeof discourseNodes)[number] | null + DiscourseNode | undefined >(() => { - if (!nodeType) return null; const node = discourseNodes.find((n) => n.type === nodeType); - return node || null; + return node; }); const nodeFormat = useMemo(() => { @@ -164,37 +166,9 @@ const ModifyNodeDialog = ({ setOptions((prev) => ({ ...prev, content: results })); } } else { - // Query all discourse node types in parallel - const allResults = await Promise.all( - discourseNodes.map(async (node) => { - const conditionUid = window.roamAlphaAPI.util.generateUID(); - const results = await fireQuery({ - returnNode: "node", - selections: [], - conditions: [ - { - source: "node", - relation: "is a", - target: node.type, - uid: conditionUid, - type: "clause", - }, - ], - }); - return results.map((r) => ({ - ...r, - discourseNodeType: node.type, - })); - }), - ); - const seen = new Set(); - const deduped = allResults.flat().filter((r) => { - if (seen.has(r.uid)) return false; - seen.add(r.uid); - return true; - }); + const results = await getAllDiscourseNodeInstances(discourseNodes); if (contentRequestIdRef.current === req && alive) { - setOptions((prev) => ({ ...prev, content: deduped })); + setOptions((prev) => ({ ...prev, content: results })); } } } catch (error) { @@ -266,8 +240,7 @@ const ModifyNodeDialog = ({ (r: Result) => { setContent(r); if (!selectedNodeType && r.uid) { - const detectedType = (r as Record) - .discourseNodeType as string | undefined; + const detectedType = r.discourseNodeType as string | undefined; if (detectedType) { const nt = discourseNodes.find((n) => n.type === detectedType); if (nt) { @@ -560,8 +533,8 @@ const ModifyNodeDialog = ({ >
{/* Content Input */} -
- +
+ {/* Node Type Selector */}
-
{/* Referenced Node Input */} {referencedNode && !isContentLocked && mode === "create" && ( -
- +
+ )}
{/* Submit Button */} diff --git a/apps/roam/src/utils/getAllDiscourseNodeInstances.ts b/apps/roam/src/utils/getAllDiscourseNodeInstances.ts new file mode 100644 index 000000000..1807c2c9d --- /dev/null +++ b/apps/roam/src/utils/getAllDiscourseNodeInstances.ts @@ -0,0 +1,47 @@ +import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression"; +import { type DiscourseNode } from "./getDiscourseNodes"; +import { type Result } from "./types"; + +export const getAllDiscourseNodeInstances = async ( + nodeTypes: DiscourseNode[], +): Promise<(Result & { discourseNodeType: string })[]> => { + if (!nodeTypes.length) return []; + + const typeMatchers = nodeTypes.map((node) => ({ + node, + regex: getDiscourseNodeFormatExpression(node.format), + })); + + const regexPattern = typeMatchers + .map(({ regex }) => `(?:${regex.source})`) + .join("|") + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + + const query = `[ + :find ?node-title ?uid + :keys text uid + :where + [(re-pattern "${regexPattern}") ?title-regex] + [?node :node/title ?node-title] + [(re-find ?title-regex ?node-title)] + [?node :block/uid ?uid] + ]`; + + const allPages = (await window.roamAlphaAPI.data.backend.q( + query, + )) as unknown[] as { text: string; uid: string }[]; + + return allPages.flatMap((page) => { + for (const { node, regex } of typeMatchers) { + if (regex.test(page.text)) { + return [ + { ...page, discourseNodeType: node.type } as Result & { + discourseNodeType: string; + }, + ]; + } + } + return []; + }); +}; From d4c7f1cece5ab5145696d70bbba27a295a00bc8a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 15 Apr 2026 14:15:41 -0400 Subject: [PATCH 6/6] address PR comments --- apps/roam/src/components/ModifyNodeDialog.tsx | 14 ++++-- .../src/utils/getAllDiscourseNodeInstances.ts | 47 ------------------- .../src/utils/getAllDiscourseNodesSince.ts | 4 +- 3 files changed, 13 insertions(+), 52 deletions(-) delete mode 100644 apps/roam/src/utils/getAllDiscourseNodeInstances.ts diff --git a/apps/roam/src/components/ModifyNodeDialog.tsx b/apps/roam/src/components/ModifyNodeDialog.tsx index a6d81378a..f68490b87 100644 --- a/apps/roam/src/components/ModifyNodeDialog.tsx +++ b/apps/roam/src/components/ModifyNodeDialog.tsx @@ -25,7 +25,7 @@ import getDiscourseNodes, { excludeDefaultNodes, type DiscourseNode, } from "~/utils/getDiscourseNodes"; -import { getAllDiscourseNodeInstances } from "~/utils/getAllDiscourseNodeInstances"; +import { getAllDiscourseNodesSince } from "~/utils/getAllDiscourseNodesSince"; import FuzzySelectInput from "./FuzzySelectInput"; import { createBlock, updateBlock } from "roamjs-components/writes"; import { @@ -166,7 +166,15 @@ const ModifyNodeDialog = ({ setOptions((prev) => ({ ...prev, content: results })); } } else { - const results = await getAllDiscourseNodeInstances(discourseNodes); + const rawResults = await getAllDiscourseNodesSince( + undefined, + discourseNodes, + ); + const results = rawResults.map((r) => ({ + text: r.text, + uid: r.source_local_id, + discourseNodeType: r.type, + })); if (contentRequestIdRef.current === req && alive) { setOptions((prev) => ({ ...prev, content: results })); } @@ -240,7 +248,7 @@ const ModifyNodeDialog = ({ (r: Result) => { setContent(r); if (!selectedNodeType && r.uid) { - const detectedType = r.discourseNodeType as string | undefined; + const detectedType = r.discourseNodeType; if (detectedType) { const nt = discourseNodes.find((n) => n.type === detectedType); if (nt) { diff --git a/apps/roam/src/utils/getAllDiscourseNodeInstances.ts b/apps/roam/src/utils/getAllDiscourseNodeInstances.ts deleted file mode 100644 index 1807c2c9d..000000000 --- a/apps/roam/src/utils/getAllDiscourseNodeInstances.ts +++ /dev/null @@ -1,47 +0,0 @@ -import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression"; -import { type DiscourseNode } from "./getDiscourseNodes"; -import { type Result } from "./types"; - -export const getAllDiscourseNodeInstances = async ( - nodeTypes: DiscourseNode[], -): Promise<(Result & { discourseNodeType: string })[]> => { - if (!nodeTypes.length) return []; - - const typeMatchers = nodeTypes.map((node) => ({ - node, - regex: getDiscourseNodeFormatExpression(node.format), - })); - - const regexPattern = typeMatchers - .map(({ regex }) => `(?:${regex.source})`) - .join("|") - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"'); - - const query = `[ - :find ?node-title ?uid - :keys text uid - :where - [(re-pattern "${regexPattern}") ?title-regex] - [?node :node/title ?node-title] - [(re-find ?title-regex ?node-title)] - [?node :block/uid ?uid] - ]`; - - const allPages = (await window.roamAlphaAPI.data.backend.q( - query, - )) as unknown[] as { text: string; uid: string }[]; - - return allPages.flatMap((page) => { - for (const { node, regex } of typeMatchers) { - if (regex.test(page.text)) { - return [ - { ...page, discourseNodeType: node.type } as Result & { - discourseNodeType: string; - }, - ]; - } - } - return []; - }); -}; diff --git a/apps/roam/src/utils/getAllDiscourseNodesSince.ts b/apps/roam/src/utils/getAllDiscourseNodesSince.ts index acff9c624..3d047f386 100644 --- a/apps/roam/src/utils/getAllDiscourseNodesSince.ts +++ b/apps/roam/src/utils/getAllDiscourseNodesSince.ts @@ -64,10 +64,10 @@ export const getDiscourseNodeTypeWithSettingsBlockNodes = async ( }; export const getAllDiscourseNodesSince = async ( - since: ISODateString, + since: ISODateString | undefined, nodeTypes: DiscourseNode[], ): Promise => { - const sinceMs = new Date(since).getTime(); + const sinceMs = since ? new Date(since).getTime() : 0; if (!nodeTypes.length) { return []; }