Skip to content

Commit d722094

Browse files
committed
Refactored the reordering so we can reuse it
1 parent 035e70a commit d722094

7 files changed

Lines changed: 558 additions & 434 deletions

File tree

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { DialogClose } from "@radix-ui/react-dialog";
2+
import { Form, useNavigation } from "@remix-run/react";
3+
import { motion } from "framer-motion";
4+
import { PlusIcon } from "@heroicons/react/20/solid";
5+
import { useEffect, useState } from "react";
6+
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
7+
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
8+
import { Feedback } from "~/components/Feedback";
9+
import { Button, LinkButton } from "../primitives/Buttons";
10+
import {
11+
Dialog,
12+
DialogContent,
13+
DialogDescription,
14+
DialogFooter,
15+
DialogHeader,
16+
DialogTrigger,
17+
} from "../primitives/Dialog";
18+
import { FormButtons } from "../primitives/FormButtons";
19+
import { Input } from "../primitives/Input";
20+
import { InputGroup } from "../primitives/InputGroup";
21+
import { Label } from "../primitives/Label";
22+
import { Paragraph } from "../primitives/Paragraph";
23+
import { TextLink } from "../primitives/TextLink";
24+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
25+
import { v3BillingPath } from "~/utils/pathBuilder";
26+
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
27+
28+
export function CreateDashboardButton({
29+
organization,
30+
project,
31+
environment,
32+
isCollapsed,
33+
}: {
34+
organization: MatchedOrganization;
35+
project: SideMenuProject;
36+
environment: SideMenuEnvironment;
37+
isCollapsed: boolean;
38+
}) {
39+
const [isOpen, setIsOpen] = useState(false);
40+
const navigation = useNavigation();
41+
const limits = useDashboardLimits();
42+
const plan = useCurrentPlan();
43+
44+
const isAtLimit = limits.used >= limits.limit;
45+
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
46+
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
47+
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
48+
49+
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
50+
51+
// Close dialog when form submission starts (redirect is happening)
52+
useEffect(() => {
53+
if (navigation.formAction === formAction && navigation.state === "loading") {
54+
setIsOpen(false);
55+
}
56+
}, [navigation.formAction, navigation.state, formAction]);
57+
58+
if (isCollapsed) return null;
59+
60+
return (
61+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
62+
<TooltipProvider disableHoverableContent>
63+
<Tooltip>
64+
<TooltipTrigger asChild>
65+
<DialogTrigger asChild>
66+
<button
67+
type="button"
68+
className="flex h-full w-full items-center justify-center rounded text-text-dimmed transition focus-custom hover:bg-charcoal-600 hover:text-text-bright"
69+
>
70+
<PlusIcon className="size-4" />
71+
</button>
72+
</DialogTrigger>
73+
</TooltipTrigger>
74+
<TooltipContent side="right" className="text-xs">
75+
Create dashboard
76+
</TooltipContent>
77+
</Tooltip>
78+
</TooltipProvider>
79+
{isAtLimit ? (
80+
<CreateDashboardUpgradeDialog
81+
limits={limits}
82+
canUpgrade={!!canUpgrade}
83+
organization={organization}
84+
/>
85+
) : (
86+
<CreateDashboardDialog formAction={formAction} limits={limits} />
87+
)}
88+
</Dialog>
89+
);
90+
}
91+
92+
const PROGRESS_RING_R = 27.5;
93+
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
94+
const PROGRESS_COLOR_SUCCESS = "#28BF5C"; // mint-500 / success
95+
const PROGRESS_COLOR_ERROR = "#E11D48"; // rose-600 / error
96+
97+
function CreateDashboardUpgradeDialog({
98+
limits,
99+
canUpgrade,
100+
organization,
101+
}: {
102+
limits: { used: number; limit: number };
103+
canUpgrade: boolean;
104+
organization: MatchedOrganization;
105+
}) {
106+
const percentage = Math.min(limits.used / limits.limit, 1);
107+
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
108+
109+
return (
110+
<DialogContent>
111+
<DialogHeader>Dashboard limit reached</DialogHeader>
112+
<div className="flex items-center gap-4 pt-3">
113+
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
114+
<svg className="h-full w-full -rotate-90 overflow-visible">
115+
<circle
116+
className="fill-none stroke-grid-bright"
117+
strokeWidth="5"
118+
r={PROGRESS_RING_R}
119+
cx="30"
120+
cy="30"
121+
/>
122+
<motion.circle
123+
className="fill-none"
124+
strokeWidth="5"
125+
r={PROGRESS_RING_R}
126+
cx="30"
127+
cy="30"
128+
strokeLinecap="round"
129+
initial={{
130+
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
131+
stroke: PROGRESS_COLOR_SUCCESS,
132+
}}
133+
animate={{
134+
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
135+
stroke: PROGRESS_COLOR_ERROR,
136+
}}
137+
transition={{ duration: 1.2, ease: "easeInOut" }}
138+
/>
139+
</svg>
140+
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
141+
{limits.limit}
142+
</span>
143+
</div>
144+
<DialogDescription className="pt-0">
145+
{canUpgrade ? (
146+
<>
147+
You've used all {limits.limit} of your custom dashboards. Upgrade your plan to create
148+
more.
149+
</>
150+
) : (
151+
<>
152+
You've used all {limits.limit} of your custom dashboards. To create more, request a
153+
limit increase or visit the{" "}
154+
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
155+
details.
156+
</>
157+
)}
158+
</DialogDescription>
159+
</div>
160+
<DialogFooter className="flex justify-between">
161+
<DialogClose asChild>
162+
<Button variant="secondary/medium">Cancel</Button>
163+
</DialogClose>
164+
{canUpgrade ? (
165+
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
166+
Upgrade plan
167+
</LinkButton>
168+
) : (
169+
<Feedback
170+
button={<Button variant="primary/medium">Request more…</Button>}
171+
defaultValue="help"
172+
/>
173+
)}
174+
</DialogFooter>
175+
</DialogContent>
176+
);
177+
}
178+
179+
function CreateDashboardDialog({
180+
formAction,
181+
limits,
182+
}: {
183+
formAction: string;
184+
limits: { used: number; limit: number };
185+
}) {
186+
const navigation = useNavigation();
187+
const [title, setTitle] = useState("");
188+
189+
const isLoading = navigation.formAction === formAction;
190+
191+
return (
192+
<DialogContent className="sm:max-w-sm">
193+
<DialogHeader>Create dashboard</DialogHeader>
194+
<Form method="post" action={formAction} className="space-y-4 pt-3">
195+
<InputGroup>
196+
<Label>Title</Label>
197+
<Input
198+
name="title"
199+
value={title}
200+
onChange={(e) => setTitle(e.target.value)}
201+
placeholder="My Dashboard"
202+
required
203+
/>
204+
</InputGroup>
205+
<Paragraph variant="extra-small" className="text-text-dimmed">
206+
{limits.used}/{limits.limit} dashboards used
207+
</Paragraph>
208+
<FormButtons
209+
confirmButton={
210+
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
211+
{isLoading ? "Creating..." : "Create"}
212+
</Button>
213+
}
214+
cancelButton={
215+
<DialogClose asChild>
216+
<Button variant="secondary/medium">Cancel</Button>
217+
</DialogClose>
218+
}
219+
/>
220+
</Form>
221+
</DialogContent>
222+
);
223+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { IconChartHistogram } from "@tabler/icons-react";
2+
import { GripVerticalIcon, LineChartIcon } from "lucide-react";
3+
import ReactGridLayout from "react-grid-layout";
4+
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
5+
import { type UserWithDashboardPreferences } from "~/models/user.server";
6+
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
7+
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
8+
import { SideMenuItem } from "./SideMenuItem";
9+
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
10+
import { useReorderableList } from "./useReorderableList";
11+
12+
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
13+
isImpersonating: boolean;
14+
};
15+
16+
export function DashboardList({
17+
organization,
18+
project,
19+
environment,
20+
isCollapsed,
21+
user,
22+
}: {
23+
organization: MatchedOrganization;
24+
project: SideMenuProject;
25+
environment: SideMenuEnvironment;
26+
isCollapsed: boolean;
27+
user: SideMenuUser;
28+
}) {
29+
const customDashboards = useCustomDashboards();
30+
const initialOrder =
31+
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
32+
"customDashboards"
33+
];
34+
35+
const {
36+
orderedItems: orderedDashboards,
37+
layout,
38+
containerRef,
39+
gridWidth,
40+
canReorder,
41+
handleDrag,
42+
handleDragStop,
43+
getIsLast,
44+
} = useReorderableList({
45+
organizationId: organization.id,
46+
listId: "customDashboards",
47+
items: customDashboards,
48+
itemKey: (d) => d.friendlyId,
49+
initialOrder,
50+
isImpersonating: user.isImpersonating,
51+
});
52+
53+
return (
54+
<div ref={containerRef}>
55+
{canReorder ? (
56+
<ReactGridLayout
57+
layout={layout}
58+
width={gridWidth}
59+
gridConfig={{
60+
cols: 1,
61+
rowHeight: 32,
62+
margin: [0, 0] as const,
63+
containerPadding: [0, 0] as const,
64+
}}
65+
resizeConfig={{ enabled: false }}
66+
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
67+
onDrag={handleDrag}
68+
onDragStop={handleDragStop}
69+
className="sidebar-reorder-grid"
70+
autoSize
71+
>
72+
{orderedDashboards.map((dashboard, index) => {
73+
const isLast = getIsLast(dashboard.friendlyId, index);
74+
return (
75+
<div key={dashboard.friendlyId}>
76+
<SideMenuItem
77+
name={dashboard.title}
78+
icon={
79+
isCollapsed
80+
? IconChartHistogram
81+
: isLast
82+
? TreeConnectorEnd
83+
: TreeConnectorBranch
84+
}
85+
activeIconColor={isCollapsed ? "text-customDashboards" : undefined}
86+
inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined}
87+
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
88+
isCollapsed={isCollapsed}
89+
action={
90+
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
91+
<GripVerticalIcon className="size-3.5" />
92+
</div>
93+
}
94+
/>
95+
</div>
96+
);
97+
})}
98+
</ReactGridLayout>
99+
) : (
100+
orderedDashboards.map((dashboard, index) => {
101+
const isLast = index === orderedDashboards.length - 1;
102+
return (
103+
<SideMenuItem
104+
key={dashboard.friendlyId}
105+
name={dashboard.title}
106+
icon={
107+
isCollapsed
108+
? LineChartIcon
109+
: isLast
110+
? TreeConnectorEnd
111+
: TreeConnectorBranch
112+
}
113+
activeIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
114+
inactiveIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
115+
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
116+
isCollapsed={isCollapsed}
117+
/>
118+
);
119+
})
120+
)}
121+
</div>
122+
);
123+
}

0 commit comments

Comments
 (0)