Skip to content

Commit 8124b51

Browse files
committed
feat: enhance dynamic text features in reports and atlas configuration
1 parent 797862c commit 8124b51

File tree

13 files changed

+596
-213
lines changed

13 files changed

+596
-213
lines changed

apps/web/components/builder/widgets/elements/WidgetElement.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ interface WidgetElementProps {
1616
onWidgetUpdate?: (newData: WidgetElementConfig) => void;
1717
fitMode?: "auto" | "contain";
1818
context?: TextEditorContext;
19+
/** Available feature attribute names for dynamic text insertion */
20+
featureAttributes?: string[];
1921
// For tabs widget
2022
projectLayers?: ProjectLayer[];
2123
projectLayerGroups?: ProjectLayerGroup[];
@@ -34,14 +36,21 @@ const WidgetElement: React.FC<WidgetElementProps> = ({
3436
viewOnly,
3537
fitMode,
3638
context,
39+
featureAttributes,
3740
projectLayers,
3841
projectLayerGroups,
3942
panelWidgets,
4043
}) => {
4144
return (
4245
<Box sx={{ width: "100%", height: fitMode === "contain" || config.type === "text" ? "100%" : undefined }}>
4346
{config.type === "text" && (
44-
<TextElementWidget config={config} viewOnly={viewOnly} context={context} onWidgetUpdate={onWidgetUpdate} />
47+
<TextElementWidget
48+
config={config}
49+
viewOnly={viewOnly}
50+
context={context}
51+
onWidgetUpdate={onWidgetUpdate}
52+
featureAttributes={featureAttributes}
53+
/>
4554
)}
4655
{config.type === "divider" && <DividerElementWidget config={config} />}
4756
{config.type === "image" && (
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
2+
import DataObjectIcon from "@mui/icons-material/DataObject";
3+
import {
4+
Divider,
5+
ListItemText,
6+
Menu,
7+
MenuItem,
8+
ToggleButton,
9+
Typography,
10+
} from "@mui/material";
11+
import type { Editor } from "@tiptap/react";
12+
import { useEffect, useState } from "react";
13+
import { useTranslation } from "react-i18next";
14+
15+
interface DynamicTextMenuProps {
16+
editor: Editor;
17+
onOpen?: () => void;
18+
onClose?: () => void;
19+
forceClose?: boolean;
20+
/** Available feature attribute names from the atlas coverage layer */
21+
featureAttributes?: string[];
22+
}
23+
24+
const DynamicTextMenu: React.FC<DynamicTextMenuProps> = ({
25+
editor,
26+
onOpen,
27+
onClose,
28+
forceClose,
29+
featureAttributes,
30+
}) => {
31+
const { t } = useTranslation("common");
32+
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
33+
const open = Boolean(anchorEl);
34+
35+
// Close when forceClose becomes true
36+
useEffect(() => {
37+
if (forceClose && anchorEl) {
38+
setAnchorEl(null);
39+
}
40+
}, [forceClose, anchorEl]);
41+
42+
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
43+
event.stopPropagation();
44+
if (anchorEl) {
45+
setAnchorEl(null);
46+
} else {
47+
onOpen?.();
48+
setAnchorEl(event.currentTarget);
49+
}
50+
};
51+
52+
const handleClose = () => {
53+
setAnchorEl(null);
54+
onClose?.();
55+
};
56+
57+
const insertPlaceholder = (text: string) => {
58+
editor.chain().focus().insertContent(text).run();
59+
handleClose();
60+
};
61+
62+
return (
63+
<>
64+
<ToggleButton
65+
value="dynamicText"
66+
size="small"
67+
selected={open}
68+
onClick={handleOpen}
69+
sx={{ display: "flex", alignItems: "center" }}>
70+
<DataObjectIcon fontSize="small" sx={{ color: open ? "primary.main" : "inherit" }} />
71+
<ArrowDropDownIcon fontSize="small" />
72+
</ToggleButton>
73+
74+
<Menu
75+
anchorEl={anchorEl}
76+
open={open}
77+
onClose={handleClose}
78+
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
79+
transformOrigin={{ vertical: "top", horizontal: "left" }}
80+
sx={{ zIndex: 1500 }}
81+
slotProps={{
82+
paper: {
83+
onMouseDown: (e: React.MouseEvent) => e.stopPropagation(),
84+
onPointerDown: (e: React.PointerEvent) => e.stopPropagation(),
85+
sx: { maxHeight: 350 },
86+
},
87+
}}>
88+
{[
89+
<MenuItem key="page_number" dense onClick={() => insertPlaceholder("{{@page_number}}")}>
90+
<ListItemText>{t("page_number_variable")}</ListItemText>
91+
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
92+
{"{{@page_number}}"}
93+
</Typography>
94+
</MenuItem>,
95+
<MenuItem key="total_pages" dense onClick={() => insertPlaceholder("{{@total_pages}}")}>
96+
<ListItemText>{t("total_pages_variable")}</ListItemText>
97+
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
98+
{"{{@total_pages}}"}
99+
</Typography>
100+
</MenuItem>,
101+
...(featureAttributes && featureAttributes.length > 0
102+
? [
103+
<Divider key="divider" />,
104+
<Typography key="header" variant="caption" color="text.secondary" sx={{ px: 2, py: 0.5, display: "block" }}>
105+
{t("feature_attribute")}
106+
</Typography>,
107+
...featureAttributes.map((attr) => (
108+
<MenuItem
109+
key={`attr-${attr}`}
110+
dense
111+
onClick={() => insertPlaceholder(`{{@feature.${attr}}}`)}>
112+
<ListItemText>{attr}</ListItemText>
113+
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
114+
{`{{@feature.${attr}}}`}
115+
</Typography>
116+
</MenuItem>
117+
)),
118+
]
119+
: []),
120+
]}
121+
</Menu>
122+
</>
123+
);
124+
};
125+
126+
export default DynamicTextMenu;

apps/web/components/builder/widgets/elements/text/Text.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { TextElementSchema } from "@/lib/validations/widget";
1919

2020
import { AlignSelect } from "@/components/builder/widgets/elements/text/AlignSelect";
2121
import { BlockTypeSelect } from "@/components/builder/widgets/elements/text/BlockTypeSelect";
22+
import DynamicTextMenu from "@/components/builder/widgets/elements/text/DynamicTextMenu";
2223
import FontFamilySelect from "@/components/builder/widgets/elements/text/FontFamilySelect";
2324
import FontSizeInput from "@/components/builder/widgets/elements/text/FontSizeInput";
2425
import LineHeightSelect from "@/components/builder/widgets/elements/text/LineHeightSelect";
@@ -114,10 +115,12 @@ const TextElementWidgetEditable = ({
114115
config,
115116
context,
116117
onWidgetUpdate,
118+
featureAttributes,
117119
}: {
118120
config: TextElementSchema;
119121
context?: TextEditorContext;
120122
onWidgetUpdate?: (newConfig: TextElementSchema) => void;
123+
featureAttributes?: string[];
121124
}) => {
122125
const [isEditMode, setIsEditMode] = useState(false);
123126
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
@@ -404,6 +407,14 @@ const TextElementWidgetEditable = ({
404407
editor={editor}
405408
onOpenChange={updateColorPickerOpen}
406409
/>
410+
<Divider flexItem orientation="vertical" />
411+
<DynamicTextMenu
412+
editor={editor}
413+
onOpen={() => updateActiveDropdown("dynamicText")}
414+
onClose={() => updateActiveDropdown(null)}
415+
forceClose={activeDropdown !== "dynamicText" && activeDropdown !== null}
416+
featureAttributes={featureAttributes}
417+
/>
407418
</>
408419
)}
409420
</Stack>
@@ -436,16 +447,23 @@ const TextElementWidget = ({
436447
viewOnly = false,
437448
context,
438449
onWidgetUpdate,
450+
featureAttributes,
439451
}: {
440452
config: TextElementSchema;
441453
viewOnly?: boolean;
442454
context?: TextEditorContext;
443455
onWidgetUpdate?: (newConfig: TextElementSchema) => void;
456+
featureAttributes?: string[];
444457
}) => {
445458
return viewOnly ? (
446459
<TextElementWidgetViewOnly config={config} />
447460
) : (
448-
<TextElementWidgetEditable config={config} context={context} onWidgetUpdate={onWidgetUpdate} />
461+
<TextElementWidgetEditable
462+
config={config}
463+
context={context}
464+
onWidgetUpdate={onWidgetUpdate}
465+
featureAttributes={featureAttributes}
466+
/>
449467
);
450468
};
451469

apps/web/components/reports/canvas/ReportsCanvas.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ interface ReportElementRendererProps {
279279
isSnappingEnabled: boolean;
280280
activeSnapGuides: SnapGuide[];
281281
atlasPage?: AtlasPage | null;
282+
featureAttributes?: string[];
282283
onSnapGuidesChange: (guides: SnapGuide[]) => void;
283284
onSelect: (id: string) => void;
284285
onDelete: (id: string) => void;
@@ -297,6 +298,7 @@ const ReportElementRenderer: React.FC<ReportElementRendererProps> = ({
297298
margins,
298299
isSnappingEnabled,
299300
atlasPage,
301+
featureAttributes,
300302
onSnapGuidesChange,
301303
onSelect,
302304
onDelete,
@@ -743,6 +745,7 @@ const ReportElementRenderer: React.FC<ReportElementRendererProps> = ({
743745
basemapUrl={basemapUrl}
744746
projectLayers={projectLayers}
745747
atlasPage={atlasPage}
748+
featureAttributes={featureAttributes}
746749
viewOnly={!isSelected}
747750
onElementUpdate={(elementId, config) => {
748751
// Update element config (e.g., map view state)
@@ -810,6 +813,12 @@ const ReportsCanvas: React.FC<ReportsCanvasProps> = ({
810813
currentPageIndex: atlasPageIndex,
811814
});
812815

816+
// Derive feature attribute names from first atlas page for dynamic text menu
817+
const featureAttributes = useMemo(() => {
818+
const props = currentAtlasPage?.feature?.properties;
819+
return props ? Object.keys(props).sort() : [];
820+
}, [currentAtlasPage]);
821+
813822
// Effective page info (atlas or single page)
814823
const isAtlasEnabled = reportConfig?.atlas?.enabled && atlasTotalPages > 0;
815824
const totalPages = isAtlasEnabled ? atlasTotalPages : 1;
@@ -1188,6 +1197,7 @@ const ReportsCanvas: React.FC<ReportsCanvasProps> = ({
11881197
isSnappingEnabled={isSnappingEnabled}
11891198
activeSnapGuides={activeSnapGuides}
11901199
atlasPage={currentAtlasPage}
1200+
featureAttributes={featureAttributes}
11911201
onSnapGuidesChange={setActiveSnapGuides}
11921202
onSelect={(id) => onElementSelect?.(id)}
11931203
onDelete={(id) => onElementDelete?.(id)}
@@ -1331,8 +1341,23 @@ const ReportsCanvas: React.FC<ReportsCanvasProps> = ({
13311341
)}
13321342
</Stack>
13331343

1334-
{/* Spacer */}
1335-
<Box sx={{ flex: 1 }} />
1344+
{/* Page Label (center) */}
1345+
<Box sx={{ flex: 1, display: "flex", justifyContent: "center", minWidth: 0 }}>
1346+
{currentAtlasPage?.label && (
1347+
<Typography
1348+
variant="body1"
1349+
fontWeight={500}
1350+
sx={{
1351+
maxWidth: 400,
1352+
overflow: "hidden",
1353+
textOverflow: "ellipsis",
1354+
whiteSpace: "nowrap",
1355+
px: 2,
1356+
}}>
1357+
{currentAtlasPage.label}
1358+
</Typography>
1359+
)}
1360+
</Box>
13361361

13371362
{/* Zoom Controls */}
13381363
<Stack direction="row" spacing={0.5} alignItems="center">

0 commit comments

Comments
 (0)