+
{
+ const target = e.target as HTMLInputElement;
+ setDiscourseNodeSetting(nodeType, ["graphOverview"], target.checked);
+ setIsGraphOverview(target.checked);
+ }}
+ >
+ Graph Overview
+
+
+
+
@@ -53,12 +80,8 @@ const DiscourseNodeCanvasSettings = ({ uid }: { uid: string }) => {
type={"color"}
value={color}
onChange={(e) => {
+ saveCanvasSetting("color", e.target.value.replace("#", ""));
setColor(e.target.value);
- setInputSetting({
- blockUid: uid,
- key: "color",
- value: e.target.value.replace("#", ""), // remove hash to not create roam link
- });
}}
/>
@@ -67,11 +90,7 @@ const DiscourseNodeCanvasSettings = ({ uid }: { uid: string }) => {
icon={color ? "delete" : "info-sign"}
onClick={() => {
setColor("");
- setInputSetting({
- blockUid: uid,
- key: "color",
- value: "",
- });
+ saveCanvasSetting("color", "");
}}
/>
@@ -83,11 +102,7 @@ const DiscourseNodeCanvasSettings = ({ uid }: { uid: string }) => {
value={alias}
onChange={(e) => {
setAlias(e.target.value);
- setInputSetting({
- blockUid: uid,
- key: "alias",
- value: e.target.value,
- });
+ saveCanvasSetting("alias", e.target.value);
}}
/>
@@ -99,17 +114,9 @@ const DiscourseNodeCanvasSettings = ({ uid }: { uid: string }) => {
setIsKeyImage(target.checked);
if (target.checked) {
if (!keyImageOption) setKeyImageOption("first-image");
- setInputSetting({
- blockUid: uid,
- key: "key-image",
- value: "true",
- });
+ saveCanvasSetting("key-image", "true");
} else {
- setInputSetting({
- blockUid: uid,
- key: "key-image",
- value: "false",
- });
+ saveCanvasSetting("key-image", "false");
}
}}
>
@@ -122,19 +129,14 @@ const DiscourseNodeCanvasSettings = ({ uid }: { uid: string }) => {
/>
- {/* */}
{
const target = e.target as HTMLInputElement;
setKeyImageOption(target.value);
- setInputSetting({
- blockUid: uid,
- key: "key-image-option",
- value: target.value,
- });
+ saveCanvasSetting("key-image-option", target.value);
}}
>
@@ -155,15 +157,11 @@ const DiscourseNodeCanvasSettings = ({ uid }: { uid: string }) => {
value={queryBuilderAlias}
onChange={(e) => {
setQueryBuilderAlias(e.target.value);
- setInputSetting({
- blockUid: uid,
- key: "query-builder-alias",
- value: e.target.value,
- });
+ saveCanvasSetting("query-builder-alias", e.target.value);
}}
/>
);
};
-export default DiscourseNodeCanvasSettings;
+export default CanvasSettings;
diff --git a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx
index e76c4a5f2..c5e4954c5 100644
--- a/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx
+++ b/apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx
@@ -8,13 +8,12 @@ import {
Tooltip,
} from "@blueprintjs/core";
import React, { useState } from "react";
-import getDiscourseNodes from "~/utils/getDiscourseNodes";
-import refreshConfigTree from "~/utils/refreshConfigTree";
-import createPage from "roamjs-components/writes/createPage";
import type { CustomField } from "roamjs-components/components/ConfigPanels/types";
import posthog from "posthog-js";
import getDiscourseRelations from "~/utils/getDiscourseRelations";
import { deleteBlock } from "roamjs-components/writes";
+import { createDiscourseNodePage } from "~/components/settings/block-prop/utils/init";
+import { getAllDiscourseNodes } from "~/components/settings/block-prop/utils/accessors";
type DiscourseNodeConfigPanelProps = React.ComponentProps<
CustomField["options"]["component"]
@@ -28,7 +27,7 @@ const DiscourseNodeConfigPanel: React.FC
= ({
setSelectedTabId,
}) => {
const [nodes, setNodes] = useState(() =>
- getDiscourseNodes().filter((n) => n.backedBy === "user"),
+ getAllDiscourseNodes().filter((n) => n.backedBy === "user"),
);
const [label, setLabel] = useState("");
const [deleteConfirmation, setDeleteConfirmation] = useState(
@@ -43,7 +42,7 @@ const DiscourseNodeConfigPanel: React.FC = ({
if (isPopup) {
setSelectedTabId(uid);
} else {
- window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } });
+ void window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } });
}
};
@@ -52,7 +51,6 @@ const DiscourseNodeConfigPanel: React.FC = ({
page: { uid },
});
setNodes((prevNodes) => prevNodes.filter((nn) => nn.type !== uid));
- refreshConfigTree();
setDeleteConfirmation(null);
};
@@ -72,41 +70,27 @@ const DiscourseNodeConfigPanel: React.FC = ({
disabled={!label}
onClick={() => {
posthog.capture("Discourse Node: Type Created", { label: label });
- createPage({
- title: `discourse-graph/nodes/${label}`,
- tree: [
- {
- text: "Shortcut",
- children: [{ text: label.slice(0, 1).toUpperCase() }],
- },
- {
- text: "Tag",
- children: [{ text: "" }],
- },
- {
- text: "Format",
- children: [
- {
- text: `[[${label.slice(0, 3).toUpperCase()}]] - {content}`,
- },
- ],
- },
- ],
- }).then((valueUid) => {
+
+ const defaultFormat = `[[${label.slice(0, 3).toUpperCase()}]] - {content}`;
+ const defaultShortcut = label.slice(0, 1).toUpperCase();
+
+ void createDiscourseNodePage(label, {
+ format: defaultFormat,
+ shortcut: defaultShortcut,
+ }).then(({ pageUid }) => {
setNodes([
...nodes,
{
- format: "",
- type: valueUid,
+ format: defaultFormat,
+ type: pageUid,
text: label,
- shortcut: "",
+ shortcut: defaultShortcut,
tag: "",
specification: [],
backedBy: "user",
canvasSettings: {},
- },
+ } as unknown as ReturnType[number],
]);
- refreshConfigTree();
setLabel("");
});
}}
@@ -176,7 +160,7 @@ const DiscourseNodeConfigPanel: React.FC = ({
setAffectedRelations(affectedRelations);
setNodeTypeIdToDelete(n.type);
} else {
- deleteNodeType(n.type);
+ void deleteNodeType(n.type);
}
}}
className={`mx-1 ${
@@ -201,25 +185,27 @@ const DiscourseNodeConfigPanel: React.FC = ({
{
+ onConfirm={() => {
if (affectedRelations.length > 0) {
- try {
- for (const rel of affectedRelations) {
- await deleteBlock(rel.id).catch((error) => {
- console.error(
- `Failed to delete relation: ${rel.id}, ${error.message}`,
- );
- throw error;
- });
+ void (async () => {
+ try {
+ for (const rel of affectedRelations) {
+ await deleteBlock(rel.id).catch((error) => {
+ console.error(
+ `Failed to delete relation: ${rel.id}, ${error.message}`,
+ );
+ throw error;
+ });
+ }
+ void deleteNodeType(nodeTypeIdToDelete);
+ } catch (error) {
+ console.error(
+ `Failed to complete deletion for UID: ${nodeTypeIdToDelete}): ${error instanceof Error ? error.message : String(error)}`,
+ );
+ } finally {
+ setIsAlertOpen(false);
}
- deleteNodeType(nodeTypeIdToDelete);
- } catch (error) {
- console.error(
- `Failed to complete deletion for UID: ${nodeTypeIdToDelete}): ${error instanceof Error ? error.message : String(error)}`,
- );
- } finally {
- setIsAlertOpen(false);
- }
+ })();
}
}}
onCancel={() => {
diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx
index d8ee9cf4e..2a1eff2d0 100644
--- a/apps/roam/src/components/settings/NodeConfig.tsx
+++ b/apps/roam/src/components/settings/NodeConfig.tsx
@@ -5,13 +5,6 @@ import React, {
useEffect,
useMemo,
} from "react";
-import { DiscourseNode } from "~/utils/getDiscourseNodes";
-import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel";
-import SelectPanel from "roamjs-components/components/ConfigPanels/SelectPanel";
-import BlocksPanel from "roamjs-components/components/ConfigPanels/BlocksPanel";
-import TextPanel from "roamjs-components/components/ConfigPanels/TextPanel";
-import { getSubTree } from "roamjs-components/util";
-import Description from "roamjs-components/components/Description";
import {
Label,
Tabs,
@@ -20,20 +13,56 @@ import {
InputGroup,
TextArea,
} from "@blueprintjs/core";
-import DiscourseNodeSpecification from "./DiscourseNodeSpecification";
-import DiscourseNodeAttributes from "./DiscourseNodeAttributes";
-import DiscourseNodeCanvasSettings from "./DiscourseNodeCanvasSettings";
-import DiscourseNodeIndex from "./DiscourseNodeIndex";
-import { OnloadArgs } from "roamjs-components/types";
-import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid";
-import createBlock from "roamjs-components/writes/createBlock";
-import updateBlock from "roamjs-components/writes/updateBlock";
-import DiscourseNodeSuggestiveRules from "./DiscourseNodeSuggestiveRules";
-import { getFormattedConfigTree } from "~/utils/discourseConfigRef";
-import refreshConfigTree from "~/utils/refreshConfigTree";
+import Description from "roamjs-components/components/Description";
+import type { DiscourseNodeSettings as DiscourseNodeSettingsType } from "~/components/settings/block-prop/utils/zodSchema";
+import {
+ getDiscourseNodeSettings,
+ setDiscourseNodeSetting,
+} from "~/components/settings/block-prop/utils/accessors";
+import { validateTagFormat, generateTagPlaceholder } from "~/components/discourse-nodes/utils";
+import CanvasSettings from "~/components/settings/DiscourseNodeCanvasSettings";
+import Attributes from "~/components/settings/DiscourseNodeAttributes";
+import { BlocksPanel } from "~/components/settings/block-prop/components/BlocksPanel";
+
+type Props = {
+ nodeType: string;
+};
+
+const useDebouncedSave = (
+ nodeType: string,
+ keys: string[],
+ initialValue: string,
+) => {
+ const [value, setValue] = useState(initialValue);
+ const debounceRef = useRef(0);
+
+ const saveToBlockProps = useCallback(
+ (text: string, immediate: boolean) => {
+ window.clearTimeout(debounceRef.current);
+ debounceRef.current = window.setTimeout(
+ () => {
+ setDiscourseNodeSetting(nodeType, keys, text);
+ },
+ immediate ? 0 : 500,
+ );
+ },
+ [nodeType, keys],
+ );
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ setValue(newValue);
+ saveToBlockProps(newValue, false);
+ },
+ [saveToBlockProps],
+ );
-export const getCleanTagText = (tag: string): string => {
- return tag.replace(/^#+/, "").trim().toUpperCase();
+ const handleBlur = useCallback(() => {
+ saveToBlockProps(value, true);
+ }, [value, saveToBlockProps]);
+
+ return { value, setValue, handleChange, handleBlur };
};
const ValidatedInputPanel = ({
@@ -50,7 +79,7 @@ const ValidatedInputPanel = ({
value: string;
onChange: (e: React.ChangeEvent) => void;
onBlur: () => void;
- error: string;
+ error?: string;
placeholder?: string;
}) => (
@@ -101,332 +130,166 @@ const ValidatedTextareaPanel = ({
);
-const useDebouncedRoamUpdater = <
- T extends HTMLInputElement | HTMLTextAreaElement,
->(
- uid: string,
- initialValue: string,
- isValid: boolean,
-) => {
- const [value, setValue] = useState(initialValue);
- const debounceRef = useRef(0);
- const isValidRef = useRef(isValid);
- isValidRef.current = isValid;
-
- const saveToRoam = useCallback(
- (text: string, timeout: boolean) => {
- window.clearTimeout(debounceRef.current);
- debounceRef.current = window.setTimeout(
- () => {
- if (!isValidRef.current) {
- return;
- }
- const existingBlock = getBasicTreeByParentUid(uid)[0];
- if (existingBlock) {
- if (existingBlock.text !== text) {
- void updateBlock({ uid: existingBlock.uid, text });
- }
- } else if (text) {
- void createBlock({ parentUid: uid, node: { text } });
- }
- },
- timeout ? 500 : 0,
- );
- },
- [uid],
- );
-
- const handleChange = useCallback(
- (e: React.ChangeEvent) => {
- const newValue = e.target.value;
- setValue(newValue);
- saveToRoam(newValue, true);
- },
- [saveToRoam],
- );
-
- const handleBlur = useCallback(() => {
- saveToRoam(value, false);
- }, [value, saveToRoam]);
-
- return { value, handleChange, handleBlur };
-};
-
-const generateTagPlaceholder = (node: DiscourseNode): string => {
- // Extract first reference from format like [[CLM]], [[QUE]], [[EVD]]
- const referenceMatch = node.format.match(/\[\[([A-Z]+)\]\]/);
-
- if (referenceMatch) {
- const reference = referenceMatch[1].toLowerCase();
- return `#${reference.slice(0, 3)}-candidate`; // [[EVD]] - {content} = #evd-candidate
- }
-
- const nodeTextPrefix = node.text.toLowerCase().slice(0, 3);
- return `#${nodeTextPrefix}-candidate`; // Evidence = #evi-candidate
-};
-
-const NodeConfig = ({
- node,
- onloadArgs,
-}: {
- node: DiscourseNode;
- onloadArgs: OnloadArgs;
-}) => {
- const settings = useMemo(() => {
- refreshConfigTree();
- return getFormattedConfigTree();
- }, []);
- const getUid = (key: string) =>
- getSubTree({
- parentUid: node.type,
- key: key,
- }).uid;
- const formatUid = getUid("Format");
- const descriptionUid = getUid("Description");
- const shortcutUid = getUid("Shortcut");
- const tagUid = getUid("Tag");
- const templateUid = getUid("Template");
- const overlayUid = getUid("Overlay");
- const canvasUid = getUid("Canvas");
- const graphOverviewUid = getUid("Graph Overview");
- const specificationUid = getUid("Specification");
- const indexUid = getUid("Index");
- const suggestiveRulesUid = getUid("Suggestive Rules");
- const attributeNode = getSubTree({
- parentUid: node.type,
- key: "Attributes",
- });
-
+const DiscourseNodeSettings = ({ nodeType }: Props) => {
const [selectedTabId, setSelectedTabId] = useState("general");
const [tagError, setTagError] = useState("");
const [formatError, setFormatError] = useState("");
- const isConfigurationValid = !tagError && !formatError;
- const {
- value: tagValue,
- handleChange: handleTagChange,
- handleBlur: handleTagBlurFromHook,
- } = useDebouncedRoamUpdater(
- tagUid,
- node.tag || "",
- isConfigurationValid,
+ const settings = useMemo(
+ () => getDiscourseNodeSettings(nodeType),
+ [nodeType],
);
- const {
- value: formatValue,
- handleChange: handleFormatChange,
- handleBlur: handleFormatBlurFromHook,
- } = useDebouncedRoamUpdater(
- formatUid,
- node.format,
- isConfigurationValid,
+
+ const description = useDebouncedSave(
+ nodeType,
+ ["description"],
+ settings?.description || "",
);
- const {
- value: descriptionValue,
- handleChange: handleDescriptionChange,
- handleBlur: handleDescriptionBlur,
- } = useDebouncedRoamUpdater(
- descriptionUid,
- node.description || "",
- true,
+ const shortcut = useDebouncedSave(
+ nodeType,
+ ["shortcut"],
+ settings?.shortcut || "",
);
+ const tag = useDebouncedSave(nodeType, ["tag"], settings?.tag || "");
+ const format = useDebouncedSave(nodeType, ["format"], settings?.format || "");
- const validate = useCallback((tag: string, format: string) => {
- const cleanTag = getCleanTagText(tag);
-
- if (!cleanTag) {
- setTagError("");
- setFormatError("");
- return;
- }
-
- const roamTagRegex = /#?\[\[(.*?)\]\]|#(\S+)/g;
- const matches = format.matchAll(roamTagRegex);
- const formatTags: string[] = [];
- for (const match of matches) {
- const tagName = match[1] || match[2];
- if (tagName) {
- formatTags.push(tagName.toUpperCase());
- }
- }
-
- const hasConflict = formatTags.includes(cleanTag);
-
- if (hasConflict) {
- setFormatError(
- `The format references the node's tag "${tag}". Please use a different format or tag.`,
- );
- setTagError(
- `The tag "${tag}" is referenced in the format. Please use a different tag or format.`,
- );
- } else {
- setTagError("");
- setFormatError("");
- }
- }, []);
-
+ // Validate tag/format on changes
useEffect(() => {
- validate(tagValue, formatValue);
- }, [tagValue, formatValue, validate]);
+ const { tagError: tErr, formatError: fErr } = validateTagFormat(
+ tag.value,
+ format.value,
+ );
+ setTagError(tErr);
+ setFormatError(fErr);
+ }, [tag.value, format.value]);
const handleTagBlur = useCallback(() => {
- handleTagBlurFromHook();
- validate(tagValue, formatValue);
- }, [handleTagBlurFromHook, tagValue, formatValue, validate]);
+ tag.handleBlur();
+ }, [tag]);
const handleFormatBlur = useCallback(() => {
- handleFormatBlurFromHook();
- validate(tagValue, formatValue);
- }, [handleFormatBlurFromHook, tagValue, formatValue, validate]);
+ format.handleBlur();
+ }, [format]);
+
+ if (!settings) {
+ return Loading node settings...
;
+ }
+
+ const nodeText = settings.text;
+ const tagPlaceholder = generateTagPlaceholder(nodeText, format.value);
return (
- <>
- setSelectedTabId(id)}
- selectedTabId={selectedTabId}
- renderActiveTabPanelOnly={true}
- >
-
-
-
-
-
- }
- />
-