1- import React , { useState } from "react" ;
1+ import React , { useState , useCallback , useRef , useEffect } from "react" ;
22import { DiscourseNode } from "~/utils/getDiscourseNodes" ;
33import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel" ;
44import SelectPanel from "roamjs-components/components/ConfigPanels/SelectPanel" ;
55import BlocksPanel from "roamjs-components/components/ConfigPanels/BlocksPanel" ;
66import TextPanel from "roamjs-components/components/ConfigPanels/TextPanel" ;
77import { getSubTree } from "roamjs-components/util" ;
88import 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" ;
1010import DiscourseNodeSpecification from "./DiscourseNodeSpecification" ;
1111import DiscourseNodeAttributes from "./DiscourseNodeAttributes" ;
1212import DiscourseNodeCanvasSettings from "./DiscourseNodeCanvasSettings" ;
1313import DiscourseNodeIndex from "./DiscourseNodeIndex" ;
1414import { 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
16102const 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
0 commit comments