Skip to content

Commit f202e8c

Browse files
authored
[ENG-77] Add formatting option for attribute select (#497)
* add formating option * address PR comment * address PR comments
1 parent 751857a commit f202e8c

1 file changed

Lines changed: 272 additions & 19 deletions

File tree

src/features/attributeSelect.tsx

Lines changed: 272 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
NumericInput,
1313
Slider,
1414
Popover,
15+
Icon,
16+
Tooltip,
1517
} from "@blueprintjs/core";
1618
import { Select } from "@blueprintjs/select";
1719
import createHTMLObserver from "roamjs-components/dom/createHTMLObserver";
@@ -35,6 +37,71 @@ import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromT
3537

3638
const CONFIG = `roam/js/attribute-select`;
3739

40+
const TEMPLATE_MAP = {
41+
"No styling": {
42+
transform: (text: string) => text,
43+
description: "No styling"
44+
},
45+
"Remove Double Brackets": {
46+
transform: (text: string) => text.replace(/\[\[(.*?)\]\]/g, '$1'),
47+
description: "Removes [[text]] format"
48+
},
49+
"Convert to Uppercase": {
50+
transform: (text: string) => text.toUpperCase(),
51+
description: "Makes text all caps"
52+
},
53+
"Capitalize Words": {
54+
transform: (text: string) => text.split(' ').map(word => {
55+
if (!word) return '';
56+
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
57+
}).join(' '),
58+
description: "Makes first letter of each word uppercase"
59+
},
60+
"Custom Format": {
61+
transform: (text: string, pattern?: string, replacement?: string) => {
62+
if (!pattern) return text;
63+
try {
64+
const regex = new RegExp(pattern);
65+
return text.replace(regex, replacement || '');
66+
} catch (e) {
67+
console.error("Invalid regex:", e);
68+
return text;
69+
}
70+
},
71+
description: "Apply custom regex pattern"
72+
}
73+
} as const;
74+
75+
type TemplateName = keyof typeof TEMPLATE_MAP;
76+
77+
type FormatParams = {
78+
text: string;
79+
templateName: string;
80+
customPattern?: string;
81+
customReplacement?: string;
82+
};
83+
84+
const applyFormatting = ({
85+
text,
86+
templateName,
87+
customPattern,
88+
customReplacement
89+
}: FormatParams): string => {
90+
try {
91+
const template = TEMPLATE_MAP[templateName as TemplateName];
92+
if (!template) return text;
93+
94+
if (templateName === "Custom Format" && customPattern) {
95+
return template.transform(text, customPattern, customReplacement);
96+
}
97+
98+
return template.transform(text);
99+
} catch (e) {
100+
console.error("Error in transform function:", e);
101+
return text;
102+
}
103+
};
104+
38105
type AttributeButtonPopoverProps<T> = {
39106
items: T[];
40107
onItemSelect?: (selectedItem: T) => void;
@@ -59,18 +126,55 @@ const AttributeButtonPopover = <T extends ReactText>({
59126
return String(item).toLowerCase().includes(query.toLowerCase());
60127
};
61128
const [sliderValue, setSliderValue] = useState(0);
129+
62130
useEffect(() => {
63131
setSliderValue(Number(currentValue));
64132
}, [isOpen, currentValue]);
133+
134+
const formatConfig = useMemo(() => {
135+
try {
136+
const configUid = getPageUidByPageTitle(CONFIG);
137+
const attributesNode = getSubTree({
138+
key: "attributes",
139+
parentUid: configUid,
140+
});
141+
const attributeUid = getSubTree({
142+
key: attributeName,
143+
parentUid: attributesNode.uid,
144+
}).uid;
145+
146+
return {
147+
templateName: getSettingValueFromTree({
148+
key: "template",
149+
parentUid: attributeUid,
150+
}) || "No styling",
151+
152+
customPattern: getSettingValueFromTree({
153+
key: "customPattern",
154+
parentUid: attributeUid,
155+
}),
156+
157+
customReplacement: getSettingValueFromTree({
158+
key: "customReplacement",
159+
parentUid: attributeUid,
160+
})
161+
};
162+
} catch (e) {
163+
console.error("Error getting format config:", e);
164+
return {
165+
templateName: "No styling",
166+
customPattern: undefined,
167+
customReplacement: undefined
168+
};
169+
}
170+
}, [attributeName]);
65171

66-
const formatDisplayText = (text: string): string => {
67-
// TODO: for doantrang982/eng-77-decouple-display-from-output: Create formatDisplayText from configPage
68-
// const match = text.match(/\[\[(.*?)\]\]/);
69-
// if (match && match[1]) {
70-
// return match[1];
71-
// }
72-
return text;
73-
};
172+
const formatText = useMemo(() =>
173+
(text: string) => applyFormatting({
174+
text,
175+
...formatConfig
176+
}),
177+
[formatConfig]);
74178

75179
// Only show filter if we have more than 10 items
76180
const shouldFilter = filterable && items.length > 10;
@@ -82,7 +186,7 @@ const AttributeButtonPopover = <T extends ReactText>({
82186
items={items}
83187
activeItem={currentValue as T}
84188
filterable={shouldFilter}
85-
// transformItem={(item) => formatDisplayText(String(item))}
189+
transformItem={(item) => formatText(String(item))}
86190
onItemSelect={(s) => {
87191
updateBlock({
88192
text: `${attributeName}:: ${s}`,
@@ -474,6 +578,35 @@ const TabsPanel = ({
474578
const [optionType, setOptionType] = useState(initialOptionType || "text");
475579
const [min, setMin] = useState(Number(rangeNode.children[0]?.text) || 0);
476580
const [max, setMax] = useState(Number(rangeNode.children[1]?.text) || 10);
581+
582+
const { initialTemplate, initialCustomPattern, initialCustomReplacement } = useMemo(() => {
583+
const savedTemplate = getSettingValueFromTree({
584+
key: "template",
585+
parentUid: attributeUid,
586+
}) || "No styling";
587+
588+
const savedCustomPattern = getSettingValueFromTree({
589+
key: "customPattern",
590+
parentUid: attributeUid,
591+
}) || "";
592+
593+
const savedCustomReplacement = getSettingValueFromTree({
594+
key: "customReplacement",
595+
parentUid: attributeUid,
596+
}) || "";
597+
598+
return {
599+
initialTemplate: savedTemplate,
600+
initialCustomPattern: savedCustomPattern,
601+
initialCustomReplacement: savedCustomReplacement
602+
};
603+
}, [attributeUid]);
604+
605+
const [selectedTemplate, setSelectedTemplate] = useState(initialTemplate);
606+
const [customPattern, setCustomPattern] = useState(initialCustomPattern);
607+
const [customReplacement, setCustomReplacement] = useState(initialCustomReplacement);
608+
const [isValidRegex, setIsValidRegex] = useState(true);
609+
477610

478611
// For a better UX replace renderBlock with a controlled list
479612
// add Edit, Delete, and Add New buttons
@@ -570,16 +703,135 @@ const TabsPanel = ({
570703
</FormGroup>
571704

572705
{optionType === "text" && (
573-
<Button
574-
intent="primary"
575-
text={"Find All Current Values"}
576-
rightIcon={"search"}
577-
onClick={() => {
578-
const potentialOptions = findAllPotentialOptions(attributeName);
579-
setPotentialOptions(potentialOptions);
580-
setShowPotentialOptions(true);
581-
}}
582-
/>
706+
<>
707+
<FormGroup
708+
label={
709+
<div className="flex items-center gap-2">
710+
<span>Display Format</span>
711+
<Tooltip
712+
content="Select a template or use a custom regex pattern"
713+
placement="top"
714+
>
715+
<Icon icon="info-sign" className="opacity-80" />
716+
</Tooltip>
717+
</div>
718+
}
719+
className="m-0"
720+
>
721+
<div className="flex flex-col space-y-2">
722+
<div className="flex items-center gap-2">
723+
<MenuItemSelect
724+
items={Object.keys(TEMPLATE_MAP)}
725+
onItemSelect={(template) => {
726+
setSelectedTemplate(template);
727+
setInputSetting({
728+
blockUid: attributeUid,
729+
key: "template",
730+
value: template,
731+
});
732+
}}
733+
activeItem={selectedTemplate}
734+
/>
735+
<Tooltip
736+
content={
737+
<div className="text-sm">
738+
<p className="font-bold mb-2">Available Templates:</p>
739+
<ul className="list-disc list-inside space-y-1">
740+
{Object.entries(TEMPLATE_MAP).map(([name, { description }]) => (
741+
<li key={name}>
742+
<span className="font-mono">{name}:</span>{" "}
743+
{description}
744+
</li>
745+
))}
746+
</ul>
747+
</div>
748+
}
749+
placement="top"
750+
>
751+
<Icon icon="info-sign" className="opacity-80" />
752+
</Tooltip>
753+
</div>
754+
755+
{selectedTemplate === "Custom Format" && (
756+
<div className="space-y-2">
757+
<FormGroup label="Pattern (regex)" className="m-0">
758+
<input
759+
className={`bp3-input font-mono text-sm w-full ${!isValidRegex ? 'bp3-intent-danger' : ''}`}
760+
placeholder="E.g., \[\[(.*?)\]\]"
761+
value={customPattern}
762+
onChange={(e) => {
763+
const newValue = e.target.value;
764+
setCustomPattern(newValue);
765+
try {
766+
if (newValue) new RegExp(newValue);
767+
setIsValidRegex(true);
768+
} catch (e) {
769+
setIsValidRegex(false);
770+
}
771+
setInputSetting({
772+
blockUid: attributeUid,
773+
key: "customPattern",
774+
value: newValue,
775+
});
776+
}}
777+
/>
778+
{!isValidRegex && (
779+
<div className="text-red-500 text-xs mt-1">
780+
Invalid regular expression
781+
</div>
782+
)}
783+
</FormGroup>
784+
785+
<FormGroup label="Replacement" className="m-0">
786+
<input
787+
className="bp3-input font-mono text-sm w-full"
788+
placeholder="E.g., $1"
789+
value={customReplacement}
790+
onChange={(e) => {
791+
const newValue = e.target.value;
792+
setCustomReplacement(newValue);
793+
setInputSetting({
794+
blockUid: attributeUid,
795+
key: "customReplacement",
796+
value: newValue,
797+
});
798+
}}
799+
/>
800+
</FormGroup>
801+
802+
<div className="bg-gray-100 p-2 rounded text-sm">
803+
<div className="font-bold mb-1">Preview:</div>
804+
<div>
805+
<span className="font-bold">Input:</span> <span className="font-mono">[[Example]]</span>
806+
</div>
807+
<div>
808+
<span className="font-bold">Output:</span> <span className="font-mono">
809+
{customPattern ?
810+
applyFormatting({
811+
text: "[[Example]]",
812+
templateName: "Custom Format",
813+
customPattern,
814+
customReplacement
815+
}) :
816+
"[[Example]]"}
817+
</span>
818+
</div>
819+
</div>
820+
</div>
821+
)}
822+
</div>
823+
</FormGroup>
824+
<Button
825+
intent="primary"
826+
text={"Find All Current Values"}
827+
rightIcon={"search"}
828+
onClick={() => {
829+
const potentialOptions = findAllPotentialOptions(attributeName);
830+
setPotentialOptions(potentialOptions);
831+
setShowPotentialOptions(true);
832+
}}
833+
/>
834+
</>
583835
)}
584836
<div
585837
className="flex items-start space-x-4"
@@ -750,6 +1002,7 @@ export const toggleFeature = async (flag: boolean) => {
7501002
.inline-menu-item-select > span > div {display:inline}
7511003
#attribute-select-config .rm-block-separator {display: none;}
7521004
`);
1005+
7531006
definedAttributes = getDefinedAttributes();
7541007
const pageUid =
7551008
getPageUidByPageTitle(CONFIG) ||

0 commit comments

Comments
 (0)