From 4a920fcc6264240a16839c676db2b51e8c4195ea Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 29 Dec 2025 21:05:22 +0530 Subject: [PATCH 1/2] migrate discourse node and discourse node panel settings to use block prop based settings --- .../components/discourse-nodes/Attributes.tsx | 138 +++++ .../discourse-nodes/CanvasSettings.tsx | 163 ++++++ .../DiscourseNodeConfigPanel.tsx | 229 ++++++++ .../discourse-nodes/DiscourseNodeSettings.tsx | 293 ++++++++++ .../src/components/discourse-nodes/utils.ts | 50 ++ .../settings/DiscourseNodeAttributes.tsx | 114 ++-- .../settings/DiscourseNodeCanvasSettings.tsx | 110 ++-- .../settings/DiscourseNodeConfigPanel.tsx | 86 ++- .../src/components/settings/NodeConfig.tsx | 527 +++++++----------- .../roam/src/components/settings/Settings.tsx | 17 +- .../block-prop/components/BlocksPanel.tsx | 70 +++ .../settings/block-prop/utils/accessors.ts | 65 ++- .../settings/block-prop/utils/init.ts | 128 ++++- .../settings/block-prop/utils/pullWatch.ts | 6 +- .../settings/block-prop/utils/zodSchema.ts | 40 +- apps/roam/src/index.ts | 3 - apps/roam/src/utils/configPageTabs.ts | 2 +- .../utils/initializeObserversAndListeners.ts | 25 +- 18 files changed, 1504 insertions(+), 562 deletions(-) create mode 100644 apps/roam/src/components/discourse-nodes/Attributes.tsx create mode 100644 apps/roam/src/components/discourse-nodes/CanvasSettings.tsx create mode 100644 apps/roam/src/components/discourse-nodes/DiscourseNodeConfigPanel.tsx create mode 100644 apps/roam/src/components/discourse-nodes/DiscourseNodeSettings.tsx create mode 100644 apps/roam/src/components/discourse-nodes/utils.ts create mode 100644 apps/roam/src/components/settings/block-prop/components/BlocksPanel.tsx diff --git a/apps/roam/src/components/discourse-nodes/Attributes.tsx b/apps/roam/src/components/discourse-nodes/Attributes.tsx new file mode 100644 index 000000000..600ef8785 --- /dev/null +++ b/apps/roam/src/components/discourse-nodes/Attributes.tsx @@ -0,0 +1,138 @@ +import { Button, InputGroup, Label, HTMLSelect } from "@blueprintjs/core"; +import Description from "roamjs-components/components/Description"; +import React, { useRef, useState } from "react"; +import { setDiscourseNodeSetting } from "~/components/settings/block-prop/utils/accessors"; + +type Attribute = { + label: string; + value: string; +}; + +const NodeAttribute = ({ + label, + value, + onChange, + onDelete, +}: Attribute & { onChange: (v: string) => void; onDelete: () => void }) => { + const timeoutRef = useRef(0); + return ( +
+ + { + clearTimeout(timeoutRef.current); + onChange(e.target.value); + timeoutRef.current = window.setTimeout(() => {}, 500); + }} + /> +
+ ); +}; + +type AttributesProps = { + nodeType: string; + attributes: Record; + overlay: string; +}; + +const Attributes = ({ nodeType, attributes, overlay }: AttributesProps) => { + const [localAttributes, setLocalAttributes] = useState(() => + Object.entries(attributes).map(([label, value]) => ({ label, value })), + ); + const [newAttribute, setNewAttribute] = useState(""); + const [selectedOverlay, setSelectedOverlay] = useState(overlay); + const timeoutRef = useRef(0); + + const saveAttribute = (label: string, value: string) => { + clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + setDiscourseNodeSetting(nodeType, ["attributes", label], value); + }, 500); + }; + + const deleteAttribute = (label: string) => { + const newAttrs = { ...attributes }; + delete newAttrs[label]; + setDiscourseNodeSetting(nodeType, ["attributes"], newAttrs); + }; + + return ( +
+
+ {localAttributes.map((a) => ( + { + saveAttribute(a.label, v); + setLocalAttributes( + localAttributes.map((aa) => + a.label === aa.label ? { ...a, value: v } : aa, + ), + ); + }} + onDelete={() => { + deleteAttribute(a.label); + setLocalAttributes( + localAttributes.filter((aa) => a.label !== aa.label), + ); + }} + /> + ))} +
+
+ +
+ setNewAttribute(e.target.value)} + /> +
+
+
+ +
+
+ ); +}; + +export default Attributes; diff --git a/apps/roam/src/components/discourse-nodes/CanvasSettings.tsx b/apps/roam/src/components/discourse-nodes/CanvasSettings.tsx new file mode 100644 index 000000000..6e47940ee --- /dev/null +++ b/apps/roam/src/components/discourse-nodes/CanvasSettings.tsx @@ -0,0 +1,163 @@ +import { + InputGroup, + Label, + Radio, + RadioGroup, + Tooltip, + Icon, + ControlGroup, + Checkbox, +} from "@blueprintjs/core"; +import React, { useState } from "react"; +import { setDiscourseNodeSetting } from "~/components/settings/block-prop/utils/accessors"; + +export const formatHexColor = (color: string) => { + if (!color) return ""; + const COLOR_TEST = /^[0-9a-f]{6}$/i; + if (color.startsWith("#")) { + return color; + } else if (COLOR_TEST.test(color)) { + return "#" + color; + } + return ""; +}; + +type CanvasSettingsProps = { + nodeType: string; + canvasSettings: Record; + graphOverview: boolean; +}; + +const CanvasSettings = ({ nodeType, canvasSettings, graphOverview }: CanvasSettingsProps) => { + const [color, setColor] = useState(() => + formatHexColor(canvasSettings.color || ""), + ); + const [alias, setAlias] = useState(canvasSettings.alias || ""); + const [queryBuilderAlias, setQueryBuilderAlias] = useState( + canvasSettings["query-builder-alias"] || "", + ); + const [isKeyImage, setIsKeyImage] = useState( + canvasSettings["key-image"] === "true", + ); + const [keyImageOption, setKeyImageOption] = useState( + canvasSettings["key-image-option"] || "first-image", + ); + const [isGraphOverview, setIsGraphOverview] = useState(graphOverview); + + const saveCanvasSetting = (key: string, value: string) => { + setDiscourseNodeSetting(nodeType, ["canvasSettings", key], value); + }; + + return ( +
+ { + const target = e.target as HTMLInputElement; + setIsGraphOverview(target.checked); + setDiscourseNodeSetting(nodeType, ["graphOverview"], target.checked); + }} + > + Graph Overview + + + + +
+ + + { + setColor(e.target.value); + saveCanvasSetting("color", e.target.value.replace("#", "")); + }} + /> + + { + setColor(""); + saveCanvasSetting("color", ""); + }} + /> + + +
+ + { + const target = e.target as HTMLInputElement; + setIsKeyImage(target.checked); + if (target.checked) { + if (!keyImageOption) setKeyImageOption("first-image"); + saveCanvasSetting("key-image", "true"); + } else { + saveCanvasSetting("key-image", "false"); + } + }} + > + Key Image + + + + + { + const target = e.target as HTMLInputElement; + setKeyImageOption(target.value); + saveCanvasSetting("key-image-option", target.value); + }} + > + + + Query Builder reference + + + + + + { + setQueryBuilderAlias(e.target.value); + saveCanvasSetting("query-builder-alias", e.target.value); + }} + /> +
+ ); +}; + +export default CanvasSettings; diff --git a/apps/roam/src/components/discourse-nodes/DiscourseNodeConfigPanel.tsx b/apps/roam/src/components/discourse-nodes/DiscourseNodeConfigPanel.tsx new file mode 100644 index 000000000..c5e4954c5 --- /dev/null +++ b/apps/roam/src/components/discourse-nodes/DiscourseNodeConfigPanel.tsx @@ -0,0 +1,229 @@ +import { + Alert, + Button, + ControlGroup, + InputGroup, + Intent, + HTMLTable, + Tooltip, +} from "@blueprintjs/core"; +import React, { useState } from "react"; +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"] +> & { + isPopup?: boolean; + setSelectedTabId: (id: string) => void; +}; + +const DiscourseNodeConfigPanel: React.FC = ({ + isPopup, + setSelectedTabId, +}) => { + const [nodes, setNodes] = useState(() => + getAllDiscourseNodes().filter((n) => n.backedBy === "user"), + ); + const [label, setLabel] = useState(""); + const [deleteConfirmation, setDeleteConfirmation] = useState( + null, + ); + + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [alertMessage, setAlertMessage] = useState(""); + const [affectedRelations, setAffectedRelations] = useState([]); + const [nodeTypeIdToDelete, setNodeTypeIdToDelete] = useState(""); + const navigateToNode = (uid: string) => { + if (isPopup) { + setSelectedTabId(uid); + } else { + void window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + } + }; + + const deleteNodeType = async (uid: string) => { + await window.roamAlphaAPI.deletePage({ + page: { uid }, + }); + setNodes((prevNodes) => prevNodes.filter((nn) => nn.type !== uid)); + setDeleteConfirmation(null); + }; + + return ( + <> + + setLabel(e.target.value)} + className={"roamjs-discourse-config-label"} + /> + + + + + ))} + + + + { + if (affectedRelations.length > 0) { + 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); + } + })(); + } + }} + onCancel={() => { + setIsAlertOpen(false); + setDeleteConfirmation(null); + }} + intent={Intent.DANGER} + confirmButtonText="Delete" + cancelButtonText="Cancel" + canEscapeKeyCancel={true} + canOutsideClickCancel={true} + > +
+ {alertMessage} +
+
+ + ); +}; + +export default DiscourseNodeConfigPanel; diff --git a/apps/roam/src/components/discourse-nodes/DiscourseNodeSettings.tsx b/apps/roam/src/components/discourse-nodes/DiscourseNodeSettings.tsx new file mode 100644 index 000000000..ee5335257 --- /dev/null +++ b/apps/roam/src/components/discourse-nodes/DiscourseNodeSettings.tsx @@ -0,0 +1,293 @@ +import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { + Label, + Tabs, + Tab, + TabId, + InputGroup, + TextArea, +} from "@blueprintjs/core"; +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 "./utils"; +import CanvasSettings from "./CanvasSettings"; +import Attributes from "./Attributes"; +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], + ); + + const handleBlur = useCallback(() => { + saveToBlockProps(value, true); + }, [value, saveToBlockProps]); + + return { value, setValue, handleChange, handleBlur }; +}; + +const ValidatedInputPanel = ({ + label, + description, + value, + onChange, + onBlur, + error, + placeholder, +}: { + label: string; + description: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + error?: string; + placeholder?: string; +}) => ( +
+ + {error && ( +
{error}
+ )} +
+); + +const ValidatedTextareaPanel = ({ + label, + description, + value, + onChange, + onBlur, + placeholder, +}: { + label: string; + description: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + placeholder?: string; +}) => ( +
+