Skip to content

Commit 59d608e

Browse files
committed
Added limits
1 parent 3cbe4e5 commit 59d608e

8 files changed

Lines changed: 416 additions & 46 deletions

File tree

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

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { DialogClose } from "@radix-ui/react-dialog";
33
import { Form, useNavigation } from "@remix-run/react";
44
import { useEffect, useState } from "react";
55
import { useEnvironment } from "~/hooks/useEnvironment";
6-
import { useCustomDashboards } from "~/hooks/useOrganizations";
6+
import {
7+
useCustomDashboards,
8+
useWidgetLimitPerDashboard,
9+
} from "~/hooks/useOrganizations";
710
import { useProject } from "~/hooks/useProject";
811
import { useOrganization } from "~/hooks/useOrganizations";
912
import { Button } from "../primitives/Buttons";
@@ -32,10 +35,14 @@ export function SaveToDashboardDialog({
3235
const project = useProject();
3336
const environment = useEnvironment();
3437
const customDashboards = useCustomDashboards();
38+
const widgetLimit = useWidgetLimitPerDashboard();
3539
const navigation = useNavigation();
3640

41+
// Find the first dashboard that isn't at the widget limit
42+
const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
43+
3744
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
38-
customDashboards.length > 0 ? customDashboards[0].friendlyId : null
45+
firstAvailableDashboard?.friendlyId ?? (customDashboards[0]?.friendlyId ?? null)
3946
);
4047

4148
// Build the form action URL
@@ -45,6 +52,10 @@ export function SaveToDashboardDialog({
4552

4653
const isLoading = navigation.formAction === formAction && navigation.state === "submitting";
4754

55+
// Check if selected dashboard is at widget limit
56+
const selectedDashboard = customDashboards.find((d) => d.friendlyId === selectedDashboardId);
57+
const isSelectedAtLimit = selectedDashboard ? selectedDashboard.widgetCount >= widgetLimit : false;
58+
4859
// Close dialog when navigation completes (redirect is happening)
4960
useEffect(() => {
5061
if (navigation.formAction === formAction && navigation.state === "loading") {
@@ -55,9 +66,10 @@ export function SaveToDashboardDialog({
5566
// Update selection if dashboards change
5667
useEffect(() => {
5768
if (customDashboards.length > 0 && !selectedDashboardId) {
58-
setSelectedDashboardId(customDashboards[0].friendlyId);
69+
const available = customDashboards.find((d) => d.widgetCount < widgetLimit);
70+
setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId);
5971
}
60-
}, [customDashboards, selectedDashboardId]);
72+
}, [customDashboards, selectedDashboardId, widgetLimit]);
6173

6274
if (customDashboards.length === 0) {
6375
return (
@@ -96,22 +108,36 @@ export function SaveToDashboardDialog({
96108
Select a dashboard to add this widget to:
97109
</Paragraph>
98110
<div className="max-h-64 space-y-1 overflow-y-auto">
99-
{customDashboards.map((dashboard) => (
100-
<button
101-
key={dashboard.friendlyId}
102-
type="button"
103-
onClick={() => setSelectedDashboardId(dashboard.friendlyId)}
104-
className={cn(
105-
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
106-
selectedDashboardId === dashboard.friendlyId
107-
? "bg-charcoal-700 text-text-bright"
108-
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
109-
)}
110-
>
111-
<ChartBarSquareIcon className="size-4 shrink-0 text-purple-500" />
112-
<span className="truncate">{dashboard.title}</span>
113-
</button>
114-
))}
111+
{customDashboards.map((dashboard) => {
112+
const isAtLimit = dashboard.widgetCount >= widgetLimit;
113+
return (
114+
<button
115+
key={dashboard.friendlyId}
116+
type="button"
117+
onClick={() => !isAtLimit && setSelectedDashboardId(dashboard.friendlyId)}
118+
disabled={isAtLimit}
119+
className={cn(
120+
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
121+
isAtLimit
122+
? "cursor-not-allowed opacity-50"
123+
: selectedDashboardId === dashboard.friendlyId
124+
? "bg-charcoal-700 text-text-bright"
125+
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
126+
)}
127+
>
128+
<ChartBarSquareIcon className="size-4 shrink-0 text-purple-500" />
129+
<span className="flex-1 truncate">{dashboard.title}</span>
130+
<span
131+
className={cn(
132+
"shrink-0 text-xs",
133+
isAtLimit ? "text-error" : "text-text-dimmed"
134+
)}
135+
>
136+
{dashboard.widgetCount}/{widgetLimit}
137+
</span>
138+
</button>
139+
);
140+
})}
115141
</div>
116142
</div>
117143

@@ -120,7 +146,7 @@ export function SaveToDashboardDialog({
120146
<Button
121147
type="submit"
122148
variant="primary/medium"
123-
disabled={isLoading || !selectedDashboardId}
149+
disabled={isLoading || !selectedDashboardId || isSelectedAtLimit}
124150
>
125151
{isLoading ? "Saving..." : "Save"}
126152
</Button>

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ import { Avatar } from "~/components/primitives/Avatar";
4040
import { type MatchedEnvironment, useEnvironment } from "~/hooks/useEnvironment";
4141
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
4242
import { useFeatures } from "~/hooks/useFeatures";
43-
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
43+
import {
44+
type MatchedOrganization,
45+
useCustomDashboards,
46+
useDashboardLimits,
47+
} from "~/hooks/useOrganizations";
4448
import { type MatchedProject, useProject } from "~/hooks/useProject";
4549
import { useHasAdminAccess } from "~/hooks/useUser";
4650
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
@@ -55,6 +59,7 @@ function getSectionCollapsed(
5559
): boolean {
5660
return sideMenu?.collapsedSections?.[sectionId] ?? false;
5761
}
62+
import { Feedback } from "~/components/Feedback";
5863
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
5964
import { type FeedbackType } from "~/routes/resources.feedback";
6065
import { IncidentStatusPanel } from "~/routes/resources.incidents";
@@ -99,7 +104,14 @@ import { FreePlanUsage } from "../billing/FreePlanUsage";
99104
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
100105
import { ImpersonationBanner } from "../ImpersonationBanner";
101106
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
102-
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
107+
import {
108+
Dialog,
109+
DialogContent,
110+
DialogDescription,
111+
DialogFooter,
112+
DialogHeader,
113+
DialogTrigger,
114+
} from "../primitives/Dialog";
103115
import { FormButtons } from "../primitives/FormButtons";
104116
import { Input } from "../primitives/Input";
105117
import { InputGroup } from "../primitives/InputGroup";
@@ -1002,6 +1014,14 @@ function CreateDashboardButton({
10021014
}) {
10031015
const [isOpen, setIsOpen] = useState(false);
10041016
const navigation = useNavigation();
1017+
const limits = useDashboardLimits();
1018+
const plan = useCurrentPlan();
1019+
1020+
const isAtLimit = limits.used >= limits.limit;
1021+
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
1022+
const canExceed =
1023+
typeof planLimits === "object" && planLimits.canExceed === true;
1024+
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
10051025

10061026
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
10071027

@@ -1033,12 +1053,57 @@ function CreateDashboardButton({
10331053
</TooltipContent>
10341054
</Tooltip>
10351055
</TooltipProvider>
1036-
<CreateDashboardDialog formAction={formAction} />
1056+
{isAtLimit ? (
1057+
<CreateDashboardUpgradeDialog
1058+
limits={limits}
1059+
canUpgrade={!!canUpgrade}
1060+
organization={organization}
1061+
/>
1062+
) : (
1063+
<CreateDashboardDialog formAction={formAction} limits={limits} />
1064+
)}
10371065
</Dialog>
10381066
);
10391067
}
10401068

1041-
function CreateDashboardDialog({ formAction }: { formAction: string }) {
1069+
function CreateDashboardUpgradeDialog({
1070+
limits,
1071+
canUpgrade,
1072+
organization,
1073+
}: {
1074+
limits: { used: number; limit: number };
1075+
canUpgrade: boolean;
1076+
organization: MatchedOrganization;
1077+
}) {
1078+
return (
1079+
<DialogContent>
1080+
<DialogHeader>You've exceeded your limit</DialogHeader>
1081+
<DialogDescription>
1082+
You've used {limits.used}/{limits.limit} of your custom dashboards.
1083+
</DialogDescription>
1084+
<DialogFooter>
1085+
{canUpgrade ? (
1086+
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
1087+
Upgrade
1088+
</LinkButton>
1089+
) : (
1090+
<Feedback
1091+
button={<Button variant="primary/small">Request more</Button>}
1092+
defaultValue="help"
1093+
/>
1094+
)}
1095+
</DialogFooter>
1096+
</DialogContent>
1097+
);
1098+
}
1099+
1100+
function CreateDashboardDialog({
1101+
formAction,
1102+
limits,
1103+
}: {
1104+
formAction: string;
1105+
limits: { used: number; limit: number };
1106+
}) {
10421107
const navigation = useNavigation();
10431108
const [title, setTitle] = useState("");
10441109

@@ -1058,6 +1123,9 @@ function CreateDashboardDialog({ formAction }: { formAction: string }) {
10581123
required
10591124
/>
10601125
</InputGroup>
1126+
<Paragraph variant="extra-small" className="text-text-dimmed">
1127+
{limits.used}/{limits.limit} dashboards used
1128+
</Paragraph>
10611129
<FormButtons
10621130
confirmButton={
10631131
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>

apps/webapp/app/hooks/useDashboardEditor.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,12 @@ export type UseDashboardEditorOptions = {
158158
widgetActionUrl: string;
159159
/** URL for layout updates. If empty or not provided, uses current page URL. */
160160
layoutActionUrl?: string;
161+
/** Maximum number of widgets allowed per dashboard. If not provided, no limit is enforced. */
162+
widgetLimit?: number;
161163
/** Callback when a sync error occurs */
162164
onSyncError?: (error: Error, action: string) => void;
165+
/** Callback when a widget action is blocked by the limit */
166+
onWidgetLimitReached?: () => void;
163167
};
164168

165169
// ============================================================================
@@ -187,7 +191,9 @@ export function useDashboardEditor({
187191
initialData,
188192
widgetActionUrl,
189193
layoutActionUrl,
194+
widgetLimit,
190195
onSyncError,
196+
onWidgetLimitReached,
191197
}: UseDashboardEditorOptions) {
192198
const [state, dispatch] = useReducer(dashboardReducer, {
193199
layout: initialData.layout,
@@ -325,6 +331,12 @@ export function useDashboardEditor({
325331

326332
const addWidget = useCallback(
327333
(title: string, query: string, config: QueryWidgetConfig) => {
334+
// Guard: check widget limit
335+
if (widgetLimit !== undefined && Object.keys(state.widgets).length >= widgetLimit) {
336+
onWidgetLimitReached?.();
337+
return;
338+
}
339+
328340
const id = nanoid(8);
329341
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
330342
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 15 };
@@ -340,7 +352,7 @@ export function useDashboardEditor({
340352
config: JSON.stringify(config),
341353
});
342354
},
343-
[state.layout, queueWidgetSync]
355+
[state.layout, state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
344356
);
345357

346358
const updateWidget = useCallback(
@@ -374,6 +386,12 @@ export function useDashboardEditor({
374386

375387
const duplicateWidget = useCallback(
376388
(widgetId: string) => {
389+
// Guard: check widget limit
390+
if (widgetLimit !== undefined && Object.keys(state.widgets).length >= widgetLimit) {
391+
onWidgetLimitReached?.();
392+
return;
393+
}
394+
377395
const newId = nanoid(8);
378396

379397
// Update local state immediately
@@ -384,7 +402,7 @@ export function useDashboardEditor({
384402
// This is fine since we're optimistic - the server state will be consistent
385403
queueWidgetSync("duplicate", { widgetId });
386404
},
387-
[queueWidgetSync]
405+
[state.widgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
388406
);
389407

390408
const renameWidget = useCallback(

apps/webapp/app/hooks/useOrganizations.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,19 @@ export function useCustomDashboards(matches?: UIMatch[]) {
7171
});
7272
return data?.customDashboards ?? [];
7373
}
74+
75+
export function useDashboardLimits(matches?: UIMatch[]) {
76+
const data = useTypedMatchesData<typeof orgLoader>({
77+
id: "routes/_app.orgs.$organizationSlug",
78+
matches,
79+
});
80+
return data?.dashboardLimits ?? { used: 0, limit: 3 };
81+
}
82+
83+
export function useWidgetLimitPerDashboard(matches?: UIMatch[]) {
84+
const data = useTypedMatchesData<typeof orgLoader>({
85+
id: "routes/_app.orgs.$organizationSlug",
86+
matches,
87+
});
88+
return data?.widgetLimitPerDashboard ?? 16;
89+
}

0 commit comments

Comments
 (0)