Skip to content

Commit 417b852

Browse files
authored
Roam: [Eng-610] tag assignment in roam dg plugin settings menu (#297)
* real time validation * use on blur validation, add tag to config page, settings, extract with other data * use live validation, don't save conflicting state to roam * address review * remove unused
1 parent 71ae29f commit 417b852

5 files changed

Lines changed: 186 additions & 9 deletions

File tree

apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ const DiscourseNodeConfigPanel: React.FC<DiscourseNodeConfigPanelProps> = ({
7979
text: "Shortcut",
8080
children: [{ text: label.slice(0, 1).toUpperCase() }],
8181
},
82+
{
83+
text: "Tag",
84+
children: [{ text: "" }],
85+
},
8286
{
8387
text: "Format",
8488
children: [
@@ -96,6 +100,7 @@ const DiscourseNodeConfigPanel: React.FC<DiscourseNodeConfigPanelProps> = ({
96100
type: valueUid,
97101
text: label,
98102
shortcut: "",
103+
tag: "",
99104
specification: [],
100105
backedBy: "user",
101106
canvasSettings: {},

apps/roam/src/components/settings/NodeConfig.tsx

Lines changed: 171 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,103 @@
1-
import React, { useState } from "react";
1+
import React, { useState, useCallback, useRef, useEffect } from "react";
22
import { DiscourseNode } from "~/utils/getDiscourseNodes";
33
import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel";
44
import SelectPanel from "roamjs-components/components/ConfigPanels/SelectPanel";
55
import BlocksPanel from "roamjs-components/components/ConfigPanels/BlocksPanel";
66
import TextPanel from "roamjs-components/components/ConfigPanels/TextPanel";
77
import { getSubTree } from "roamjs-components/util";
88
import Description from "roamjs-components/components/Description";
9-
import { Label, Tabs, Tab, TabId } from "@blueprintjs/core";
9+
import { Label, Tabs, Tab, TabId, InputGroup } from "@blueprintjs/core";
1010
import DiscourseNodeSpecification from "./DiscourseNodeSpecification";
1111
import DiscourseNodeAttributes from "./DiscourseNodeAttributes";
1212
import DiscourseNodeCanvasSettings from "./DiscourseNodeCanvasSettings";
1313
import DiscourseNodeIndex from "./DiscourseNodeIndex";
1414
import { OnloadArgs } from "roamjs-components/types";
15+
import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid";
16+
import createBlock from "roamjs-components/writes/createBlock";
17+
import updateBlock from "roamjs-components/writes/updateBlock";
18+
19+
const ValidatedInputPanel = ({
20+
label,
21+
description,
22+
value,
23+
onChange,
24+
onBlur,
25+
error,
26+
placeholder,
27+
}: {
28+
label: string;
29+
description: string;
30+
value: string;
31+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
32+
onBlur: () => void;
33+
error: string;
34+
placeholder?: string;
35+
}) => (
36+
<>
37+
<Label>
38+
{label}
39+
<Description description={description} />
40+
<InputGroup
41+
value={value}
42+
onChange={onChange}
43+
onBlur={onBlur}
44+
placeholder={placeholder}
45+
/>
46+
</Label>
47+
{error && (
48+
<div className="mt-1 text-sm font-medium text-red-600">{error}</div>
49+
)}
50+
</>
51+
);
52+
53+
const useDebouncedRoamUpdater = (
54+
uid: string,
55+
initialValue: string,
56+
isValid: boolean,
57+
) => {
58+
const [value, setValue] = useState(initialValue);
59+
const debounceRef = useRef(0);
60+
const isValidRef = useRef(isValid);
61+
isValidRef.current = isValid;
62+
63+
const saveToRoam = useCallback(
64+
(text: string, timeout: boolean) => {
65+
window.clearTimeout(debounceRef.current);
66+
debounceRef.current = window.setTimeout(
67+
() => {
68+
if (!isValidRef.current) {
69+
return;
70+
}
71+
const existingBlock = getBasicTreeByParentUid(uid)[0];
72+
if (existingBlock) {
73+
if (existingBlock.text !== text) {
74+
updateBlock({ uid: existingBlock.uid, text });
75+
}
76+
} else if (text) {
77+
createBlock({ parentUid: uid, node: { text } });
78+
}
79+
},
80+
timeout ? 500 : 0,
81+
);
82+
},
83+
[uid],
84+
);
85+
86+
const handleChange = useCallback(
87+
(e: React.ChangeEvent<HTMLInputElement>) => {
88+
const newValue = e.target.value;
89+
setValue(newValue);
90+
saveToRoam(newValue, true);
91+
},
92+
[saveToRoam],
93+
);
94+
95+
const handleBlur = useCallback(() => {
96+
saveToRoam(value, false);
97+
}, [value, saveToRoam]);
98+
99+
return { value, handleChange, handleBlur };
100+
};
15101

16102
const NodeConfig = ({
17103
node,
@@ -28,6 +114,7 @@ const NodeConfig = ({
28114
const formatUid = getUid("Format");
29115
const descriptionUid = getUid("Description");
30116
const shortcutUid = getUid("Shortcut");
117+
const tagUid = getUid("Tag");
31118
const templateUid = getUid("Template");
32119
const overlayUid = getUid("Overlay");
33120
const canvasUid = getUid("Canvas");
@@ -40,6 +127,72 @@ const NodeConfig = ({
40127
});
41128

42129
const [selectedTabId, setSelectedTabId] = useState<TabId>("general");
130+
const [tagError, setTagError] = useState("");
131+
const [formatError, setFormatError] = useState("");
132+
const isConfigurationValid = !tagError && !formatError;
133+
134+
const {
135+
value: tagValue,
136+
handleChange: handleTagChange,
137+
handleBlur: handleTagBlurFromHook,
138+
} = useDebouncedRoamUpdater(tagUid, node.tag || "", isConfigurationValid);
139+
const {
140+
value: formatValue,
141+
handleChange: handleFormatChange,
142+
handleBlur: handleFormatBlurFromHook,
143+
} = useDebouncedRoamUpdater(formatUid, node.format, isConfigurationValid);
144+
145+
const getCleanTagText = (tag: string): string => {
146+
return tag.replace(/^#+/, "").trim().toUpperCase();
147+
};
148+
149+
const validate = useCallback((tag: string, format: string) => {
150+
const cleanTag = getCleanTagText(tag);
151+
152+
if (!cleanTag) {
153+
setTagError("");
154+
setFormatError("");
155+
return;
156+
}
157+
158+
const roamTagRegex = /#?\[\[(.*?)\]\]|#(\S+)/g;
159+
const matches = format.matchAll(roamTagRegex);
160+
const formatTags: string[] = [];
161+
for (const match of matches) {
162+
const tagName = match[1] || match[2];
163+
if (tagName) {
164+
formatTags.push(tagName.toUpperCase());
165+
}
166+
}
167+
168+
const hasConflict = formatTags.includes(cleanTag);
169+
170+
if (hasConflict) {
171+
setFormatError(
172+
`The format references the node's tag "${tag}". Please use a different format or tag.`,
173+
);
174+
setTagError(
175+
`The tag "${tag}" is referenced in the format. Please use a different tag or format.`,
176+
);
177+
} else {
178+
setTagError("");
179+
setFormatError("");
180+
}
181+
}, []);
182+
183+
useEffect(() => {
184+
validate(tagValue, formatValue);
185+
}, [tagValue, formatValue, validate]);
186+
187+
const handleTagBlur = useCallback(() => {
188+
handleTagBlurFromHook();
189+
validate(tagValue, formatValue);
190+
}, [handleTagBlurFromHook, tagValue, formatValue, validate]);
191+
192+
const handleFormatBlur = useCallback(() => {
193+
handleFormatBlurFromHook();
194+
validate(tagValue, formatValue);
195+
}, [handleFormatBlurFromHook, tagValue, formatValue, validate]);
43196

44197
return (
45198
<>
@@ -52,7 +205,7 @@ const NodeConfig = ({
52205
id="general"
53206
title="General"
54207
panel={
55-
<div className="flex flex-col gap-4 p-1">
208+
<div className="flex flex-row gap-4 p-1">
56209
<TextPanel
57210
title="Description"
58211
description={`Describing what the ${node.text} node represents in your graph.`}
@@ -69,6 +222,15 @@ const NodeConfig = ({
69222
uid={shortcutUid}
70223
defaultValue={node.shortcut}
71224
/>
225+
<ValidatedInputPanel
226+
label="Tag"
227+
description={`Designate a hashtag for marking potential ${node.text}.`}
228+
value={tagValue}
229+
onChange={handleTagChange}
230+
onBlur={handleTagBlur}
231+
error={tagError}
232+
placeholder={`#${node.text}`}
233+
/>
72234
</div>
73235
}
74236
/>
@@ -90,13 +252,13 @@ const NodeConfig = ({
90252
title="Format"
91253
panel={
92254
<div className="flex flex-col gap-4 p-1">
93-
<TextPanel
94-
title="Format"
255+
<ValidatedInputPanel
256+
label="Format"
95257
description={`DEPRECATED - Use specification instead. The format ${node.text} pages should have.`}
96-
order={0}
97-
parentUid={node.type}
98-
uid={formatUid}
99-
defaultValue={node.format}
258+
value={formatValue}
259+
onChange={handleFormatChange}
260+
onBlur={handleFormatBlur}
261+
error={formatError}
100262
/>
101263
<Label>
102264
Specification

apps/roam/src/utils/getDiscourseNodes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type DiscourseNode = {
1515
text: string;
1616
type: string;
1717
shortcut: string;
18+
tag?: string;
1819
specification: Condition[];
1920
backedBy: "user" | "default" | "relation";
2021
canvasSettings: {
@@ -85,6 +86,7 @@ const getDiscourseNodes = (relations = getDiscourseRelations()) => {
8586
format: getSettingValueFromTree({ tree: children, key: "format" }),
8687
text,
8788
shortcut: getSettingValueFromTree({ tree: children, key: "shortcut" }),
89+
tag: getSettingValueFromTree({ tree: children, key: "tag" }),
8890
type,
8991
specification: getSpecification(children),
9092
backedBy: "user",

apps/roam/src/utils/initializeDiscourseNodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const initializeDiscourseNodes = async () => {
1616
tree: [
1717
{ text: "Format", children: [{ text: n.format || "" }] },
1818
{ text: "Shortcut", children: [{ text: n.shortcut || "" }] },
19+
{ text: "Tag", children: [{ text: n.tag || "" }] },
1920
{ text: "Graph Overview" },
2021
{
2122
text: "Canvas",

apps/roam/src/utils/renderNodeConfigPage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ export const renderNodeConfigPage = ({
9090
// @ts-ignore
9191
Panel: TextPanel,
9292
},
93+
{
94+
title: "Tag",
95+
description: `Designate a hashtag for marking potential ${nodeText}.`,
96+
defaultValue: "",
97+
// @ts-ignore
98+
Panel: TextPanel,
99+
},
93100
{
94101
title: "Description",
95102
description: `Describing what the ${nodeText} node represents in your graph.`,

0 commit comments

Comments
 (0)