Skip to content

Commit c954ae4

Browse files
committed
Added a title widget type and the ability to add them
1 parent 5c9d995 commit c954ae4

7 files changed

Lines changed: 351 additions & 85 deletions

File tree

apps/webapp/app/components/metrics/QueryWidget.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ export const QueryWidgetConfig = z.discriminatedUnion("type", [
105105
type: z.literal("bignumber"),
106106
...bigNumberConfigOptions,
107107
}),
108+
z.object({
109+
type: z.literal("title"),
110+
}),
108111
]);
109112

110113
export type QueryWidgetConfig = z.infer<typeof QueryWidgetConfig>;
@@ -394,6 +397,10 @@ function QueryWidgetBody({
394397
</>
395398
);
396399
}
400+
case "title": {
401+
// Title widgets are rendered by TitleWidget, not QueryWidget
402+
return null;
403+
}
397404
default: {
398405
assertNever(type);
399406
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { useState } from "react";
2+
import { PencilIcon, TrashIcon } from "@heroicons/react/20/solid";
3+
import { cn } from "~/utils/cn";
4+
import { Button } from "../primitives/Buttons";
5+
import {
6+
Popover,
7+
PopoverContent,
8+
PopoverMenuItem,
9+
PopoverVerticalEllipseTrigger,
10+
} from "../primitives/Popover";
11+
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
12+
import { DialogClose } from "@radix-ui/react-dialog";
13+
import { Input } from "../primitives/Input";
14+
import { InputGroup } from "../primitives/InputGroup";
15+
import { Label } from "../primitives/Label";
16+
17+
export type TitleWidgetProps = {
18+
title: string;
19+
isDraggable?: boolean;
20+
isResizing?: boolean;
21+
/** Callback when rename is clicked. Receives the new title. */
22+
onRename?: (newTitle: string) => void;
23+
/** Callback when delete is clicked. */
24+
onDelete?: () => void;
25+
};
26+
27+
export function TitleWidget({
28+
title,
29+
isDraggable,
30+
isResizing,
31+
onRename,
32+
onDelete,
33+
}: TitleWidgetProps) {
34+
const [isMenuOpen, setIsMenuOpen] = useState(false);
35+
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
36+
const [renameValue, setRenameValue] = useState(title);
37+
38+
const hasMenu = onRename || onDelete;
39+
40+
return (
41+
<div className="h-full">
42+
<div
43+
className={cn(
44+
"group flex h-full items-center gap-2 rounded-lg border border-grid-bright bg-background-bright px-4",
45+
isDraggable && "drag-handle cursor-grab active:cursor-grabbing",
46+
isResizing && "select-none"
47+
)}
48+
>
49+
<span className="min-w-0 flex-1 truncate text-lg font-medium text-text-bright">
50+
{title}
51+
</span>
52+
{hasMenu && (
53+
<div className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
54+
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
55+
<PopoverVerticalEllipseTrigger isOpen={isMenuOpen} />
56+
<PopoverContent align="end" className="p-0">
57+
<div className="flex flex-col gap-1 p-1">
58+
{onRename && (
59+
<PopoverMenuItem
60+
icon={PencilIcon}
61+
title="Rename"
62+
onClick={() => {
63+
setRenameValue(title);
64+
setIsRenameDialogOpen(true);
65+
setIsMenuOpen(false);
66+
}}
67+
/>
68+
)}
69+
{onDelete && (
70+
<PopoverMenuItem
71+
icon={TrashIcon}
72+
title="Delete"
73+
leadingIconClassName="text-error"
74+
className="text-error hover:!bg-error/10"
75+
onClick={() => {
76+
onDelete();
77+
setIsMenuOpen(false);
78+
}}
79+
/>
80+
)}
81+
</div>
82+
</PopoverContent>
83+
</Popover>
84+
</div>
85+
)}
86+
</div>
87+
88+
{/* Rename Dialog */}
89+
{onRename && (
90+
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
91+
<DialogContent className="sm:max-w-md">
92+
<DialogHeader>Rename title</DialogHeader>
93+
<form
94+
className="space-y-4 pt-3"
95+
onSubmit={(e) => {
96+
e.preventDefault();
97+
if (renameValue.trim()) {
98+
onRename(renameValue.trim());
99+
setIsRenameDialogOpen(false);
100+
}
101+
}}
102+
>
103+
<InputGroup>
104+
<Label>Title</Label>
105+
<Input
106+
value={renameValue}
107+
onChange={(e) => setRenameValue(e.target.value)}
108+
placeholder="Section title"
109+
autoFocus
110+
/>
111+
</InputGroup>
112+
<DialogFooter>
113+
<DialogClose asChild>
114+
<Button variant="tertiary/medium">Cancel</Button>
115+
</DialogClose>
116+
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
117+
Save
118+
</Button>
119+
</DialogFooter>
120+
</form>
121+
</DialogContent>
122+
</Dialog>
123+
)}
124+
</div>
125+
);
126+
}

apps/webapp/app/hooks/useDashboardEditor.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -329,10 +329,15 @@ export function useDashboardEditor({
329329
// Action handlers
330330
// -------------------------------------------------------------------------
331331

332+
// Count only non-title widgets for limit checks (title widgets are free)
333+
const countedWidgets = Object.values(state.widgets).filter(
334+
(w) => w.display.type !== "title"
335+
).length;
336+
332337
const addWidget = useCallback(
333338
(title: string, query: string, config: QueryWidgetConfig) => {
334-
// Guard: check widget limit
335-
if (widgetLimit !== undefined && Object.keys(state.widgets).length >= widgetLimit) {
339+
// Guard: check widget limit (title widgets don't count)
340+
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
336341
onWidgetLimitReached?.();
337342
return;
338343
}
@@ -352,7 +357,29 @@ export function useDashboardEditor({
352357
config: JSON.stringify(config),
353358
});
354359
},
355-
[state.layout, state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
360+
[state.layout, countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
361+
);
362+
363+
const addTitleWidget = useCallback(
364+
(title: string) => {
365+
const id = nanoid(8);
366+
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
367+
// Title widgets are fixed at h=2 and full width
368+
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 2 };
369+
const config: QueryWidgetConfig = { type: "title" };
370+
const widget: Widget = { title, query: "", display: config };
371+
372+
// Update local state immediately
373+
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
374+
375+
// Queue sync to server (processed sequentially)
376+
queueWidgetSync("add", {
377+
title,
378+
query: "",
379+
config: JSON.stringify(config),
380+
});
381+
},
382+
[state.layout, queueWidgetSync]
356383
);
357384

358385
const updateWidget = useCallback(
@@ -386,8 +413,8 @@ export function useDashboardEditor({
386413

387414
const duplicateWidget = useCallback(
388415
(widgetId: string) => {
389-
// Guard: check widget limit
390-
if (widgetLimit !== undefined && Object.keys(state.widgets).length >= widgetLimit) {
416+
// Guard: check widget limit (title widgets don't count)
417+
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
391418
onWidgetLimitReached?.();
392419
return;
393420
}
@@ -402,7 +429,7 @@ export function useDashboardEditor({
402429
// This is fine since we're optimistic - the server state will be consistent
403430
queueWidgetSync("duplicate", { widgetId });
404431
},
405-
[state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
432+
[countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
406433
);
407434

408435
const renameWidget = useCallback(
@@ -471,6 +498,7 @@ export function useDashboardEditor({
471498
/** Action dispatchers */
472499
actions: {
473500
addWidget,
501+
addTitleWidget,
474502
updateWidget,
475503
renameWidget,
476504
deleteWidget,

apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ export const LayoutItem = z.object({
2626
y: z.number(),
2727
w: z.number(),
2828
h: z.number(),
29+
minH: z.number().optional(),
30+
maxH: z.number().optional(),
2931
});
3032

3133
export type LayoutItem = z.infer<typeof LayoutItem>;
3234

3335
export const Widget = z.object({
3436
title: z.string(),
35-
query: z.string(),
37+
query: z.string().default(""),
3638
display: QueryWidgetConfig,
3739
});
3840

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
1515
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
1616
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
1717
import { z } from "zod";
18-
import { useCallback, useEffect, useRef, useState } from "react";
18+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1919
import ReactGridLayout from "react-grid-layout";
2020
import { MetricWidget } from "../resources.metric";
21+
import { TitleWidget } from "~/components/metrics/TitleWidget";
2122
import { useOrganization } from "~/hooks/useOrganizations";
2223
import { useProject } from "~/hooks/useProject";
2324
import { useEnvironment } from "~/hooks/useEnvironment";
@@ -153,6 +154,19 @@ export function MetricDashboard({
153154
[onLayoutChange]
154155
);
155156

157+
// Apply constraints for title widgets: fixed height of 2, allow horizontal resize only
158+
const constrainedLayout = useMemo(
159+
() =>
160+
layout.map((item) => {
161+
const widget = widgets[item.i];
162+
if (widget?.display.type === "title") {
163+
return { ...item, h: 2, minH: 2, maxH: 2 };
164+
}
165+
return item;
166+
}),
167+
[layout, widgets]
168+
);
169+
156170
return (
157171
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
158172
<div className="flex items-center gap-1 border-b border-b-grid-bright py-2 pl-2 pr-3">
@@ -173,7 +187,7 @@ export function MetricDashboard({
173187
>
174188
{mounted && (
175189
<ReactGridLayout
176-
layout={layout}
190+
layout={constrainedLayout}
177191
width={width}
178192
gridConfig={{ cols: 12, rowHeight: 30 }}
179193
resizeConfig={{
@@ -187,38 +201,50 @@ export function MetricDashboard({
187201
>
188202
{Object.entries(widgets).map(([key, widget]) => (
189203
<div key={key}>
190-
<MetricWidget
191-
widgetKey={key}
192-
title={widget.title}
193-
query={widget.query}
194-
scope={scope}
195-
period={period ?? null}
196-
from={from ?? null}
197-
to={to ?? null}
198-
taskIdentifiers={tasks.length > 0 ? tasks : undefined}
199-
queues={queues.length > 0 ? queues : undefined}
200-
config={widget.display}
201-
organizationId={organization.id}
202-
projectId={project.id}
203-
environmentId={environment.id}
204-
refreshIntervalMs={60_000}
205-
isResizing={resizingItemId === key}
206-
isDraggable={editable}
207-
onEdit={
208-
onEditWidget
209-
? (resultData) => onEditWidget(key, { ...widget, resultData })
210-
: undefined
211-
}
212-
onRename={
213-
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
214-
}
215-
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
216-
onDuplicate={
217-
onDuplicateWidget
218-
? (resultData) => onDuplicateWidget(key, { ...widget, resultData })
219-
: undefined
220-
}
221-
/>
204+
{widget.display.type === "title" ? (
205+
<TitleWidget
206+
title={widget.title}
207+
isDraggable={editable}
208+
isResizing={resizingItemId === key}
209+
onRename={
210+
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
211+
}
212+
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
213+
/>
214+
) : (
215+
<MetricWidget
216+
widgetKey={key}
217+
title={widget.title}
218+
query={widget.query}
219+
scope={scope}
220+
period={period ?? null}
221+
from={from ?? null}
222+
to={to ?? null}
223+
taskIdentifiers={tasks.length > 0 ? tasks : undefined}
224+
queues={queues.length > 0 ? queues : undefined}
225+
config={widget.display}
226+
organizationId={organization.id}
227+
projectId={project.id}
228+
environmentId={environment.id}
229+
refreshIntervalMs={60_000}
230+
isResizing={resizingItemId === key}
231+
isDraggable={editable}
232+
onEdit={
233+
onEditWidget
234+
? (resultData) => onEditWidget(key, { ...widget, resultData })
235+
: undefined
236+
}
237+
onRename={
238+
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
239+
}
240+
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
241+
onDuplicate={
242+
onDuplicateWidget
243+
? (resultData) => onDuplicateWidget(key, { ...widget, resultData })
244+
: undefined
245+
}
246+
/>
247+
)}
222248
</div>
223249
))}
224250
</ReactGridLayout>

0 commit comments

Comments
 (0)