327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { DialogClose } from "@radix-ui/react-dialog";
|
|
import { Form, useNavigation } from "@remix-run/react";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
|
import { PlusIcon } from "@heroicons/react/20/solid";
|
|
import { useEffect, useState } from "react";
|
|
import { type ShortcutDefinition } from "~/hooks/useShortcutKeys";
|
|
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
|
|
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
|
import { Feedback } from "~/components/Feedback";
|
|
import { Button, LinkButton } from "../primitives/Buttons";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTrigger,
|
|
} from "../primitives/Dialog";
|
|
import { FormButtons } from "../primitives/FormButtons";
|
|
import { Input } from "../primitives/Input";
|
|
import { InputGroup } from "../primitives/InputGroup";
|
|
import { Label } from "../primitives/Label";
|
|
import { Paragraph } from "../primitives/Paragraph";
|
|
import { TextLink } from "../primitives/TextLink";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
|
import { useThemeColor } from "~/hooks/useThemeColor";
|
|
import { v3BillingPath } from "~/utils/pathBuilder";
|
|
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
|
|
|
function useCreateDashboard({
|
|
organization,
|
|
project,
|
|
environment,
|
|
}: {
|
|
organization: { slug: string };
|
|
project: { slug: string };
|
|
environment: { slug: string };
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const navigation = useNavigation();
|
|
const limits = useDashboardLimits();
|
|
const plan = useCurrentPlan();
|
|
|
|
const isAtLimit = limits.used >= limits.limit;
|
|
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
|
|
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
|
|
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
|
|
const isFreePlan = plan?.v3Subscription?.isPaying === false;
|
|
|
|
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
|
|
|
|
useEffect(() => {
|
|
if (navigation.formAction === formAction && navigation.state === "loading") {
|
|
setIsOpen(false);
|
|
}
|
|
}, [navigation.formAction, navigation.state, formAction]);
|
|
|
|
return {
|
|
isOpen,
|
|
setIsOpen,
|
|
isAtLimit,
|
|
canUpgrade: !!canUpgrade,
|
|
isFreePlan,
|
|
formAction,
|
|
limits,
|
|
organization,
|
|
};
|
|
}
|
|
|
|
export function CreateDashboardButton({
|
|
organization,
|
|
project,
|
|
environment,
|
|
isCollapsed,
|
|
}: {
|
|
organization: MatchedOrganization;
|
|
project: SideMenuProject;
|
|
environment: SideMenuEnvironment;
|
|
isCollapsed: boolean;
|
|
}) {
|
|
const dashboard = useCreateDashboard({ organization, project, environment });
|
|
|
|
if (isCollapsed) return null;
|
|
|
|
return (
|
|
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
|
|
<TooltipProvider disableHoverableContent>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DialogTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex h-full w-full items-center justify-center rounded text-text-dimmed focus-custom hover:bg-surface-control hover:text-text-bright"
|
|
>
|
|
<PlusIcon className="size-4" />
|
|
</button>
|
|
</DialogTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="text-xs">
|
|
Create dashboard
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
{dashboard.isAtLimit ? (
|
|
<CreateDashboardUpgradeDialog
|
|
limits={dashboard.limits}
|
|
canUpgrade={dashboard.canUpgrade}
|
|
isFreePlan={dashboard.isFreePlan}
|
|
organization={dashboard.organization}
|
|
/>
|
|
) : (
|
|
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function CreateDashboardPageButton({
|
|
organization,
|
|
project,
|
|
environment,
|
|
shortcut,
|
|
children,
|
|
}: {
|
|
organization: { slug: string };
|
|
project: { slug: string };
|
|
environment: { slug: string };
|
|
shortcut?: ShortcutDefinition;
|
|
/** Custom trigger element. When omitted, a default primary button is rendered. */
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const dashboard = useCreateDashboard({ organization, project, environment });
|
|
|
|
return (
|
|
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
|
|
<DialogTrigger asChild>
|
|
{children ?? (
|
|
<Button
|
|
variant="primary/small"
|
|
LeadingIcon={PlusIcon}
|
|
shortcut={shortcut}
|
|
className="pr-2"
|
|
>
|
|
Create custom dashboard
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
{dashboard.isAtLimit ? (
|
|
<CreateDashboardUpgradeDialog
|
|
limits={dashboard.limits}
|
|
canUpgrade={dashboard.canUpgrade}
|
|
isFreePlan={dashboard.isFreePlan}
|
|
organization={dashboard.organization}
|
|
/>
|
|
) : (
|
|
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
|
|
)}
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const PROGRESS_RING_R = 27.5;
|
|
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
|
|
|
|
function CreateDashboardUpgradeDialog({
|
|
limits,
|
|
canUpgrade,
|
|
isFreePlan,
|
|
organization,
|
|
}: {
|
|
limits: { used: number; limit: number };
|
|
canUpgrade: boolean;
|
|
isFreePlan: boolean;
|
|
organization: { slug: string };
|
|
}) {
|
|
// Resolved to concrete colors - framer-motion can't interpolate var() strings.
|
|
// Must be called before the early return below (rules of hooks).
|
|
const progressColorSuccess = useThemeColor("--color-success", "#28bf5c");
|
|
const progressColorError = useThemeColor("--color-error", "#e11d48");
|
|
|
|
if (isFreePlan) {
|
|
return (
|
|
<DialogContent>
|
|
<DialogHeader>Upgrade to unlock dashboards</DialogHeader>
|
|
<div className="flex items-center gap-4 pt-3">
|
|
<ArrowUpCircleIcon className="ml-1 size-14 shrink-0 text-indigo-500" />
|
|
<DialogDescription className="pt-0">
|
|
Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
|
|
track your task metrics.
|
|
</DialogDescription>
|
|
</div>
|
|
<DialogFooter className="flex justify-between">
|
|
<DialogClose asChild>
|
|
<Button variant="secondary/medium">Cancel</Button>
|
|
</DialogClose>
|
|
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
|
|
Upgrade plan
|
|
</LinkButton>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
);
|
|
}
|
|
|
|
const percentage = Math.min(limits.used / limits.limit, 1);
|
|
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
|
|
|
|
return (
|
|
<DialogContent>
|
|
<DialogHeader>Dashboard limit reached</DialogHeader>
|
|
<div className="flex items-center gap-4 pt-3">
|
|
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
|
|
<svg className="h-full w-full -rotate-90 overflow-visible">
|
|
<circle
|
|
className="fill-none stroke-grid-bright"
|
|
strokeWidth="5"
|
|
r={PROGRESS_RING_R}
|
|
cx="30"
|
|
cy="30"
|
|
/>
|
|
<motion.circle
|
|
className="fill-none"
|
|
strokeWidth="5"
|
|
r={PROGRESS_RING_R}
|
|
cx="30"
|
|
cy="30"
|
|
strokeLinecap="round"
|
|
initial={{
|
|
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
|
|
stroke: progressColorSuccess,
|
|
}}
|
|
animate={{
|
|
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
|
|
stroke: progressColorError,
|
|
}}
|
|
transition={{ duration: 1.2, ease: "easeInOut" }}
|
|
/>
|
|
</svg>
|
|
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
|
|
{limits.limit}
|
|
</span>
|
|
</div>
|
|
<DialogDescription className="pt-0">
|
|
{canUpgrade ? (
|
|
<>
|
|
{limits.limit === 1
|
|
? "Your plan includes 1 custom dashboard and it's already in use."
|
|
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
|
|
Upgrade your plan to create more.
|
|
</>
|
|
) : (
|
|
<>
|
|
{limits.limit === 1
|
|
? "Your plan includes 1 custom dashboard and it's already in use."
|
|
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
|
|
To create more, request a limit increase or visit the{" "}
|
|
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
|
|
details.
|
|
</>
|
|
)}
|
|
</DialogDescription>
|
|
</div>
|
|
<DialogFooter className="flex justify-between">
|
|
<DialogClose asChild>
|
|
<Button variant="secondary/medium">Cancel</Button>
|
|
</DialogClose>
|
|
{canUpgrade ? (
|
|
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
|
|
Upgrade plan
|
|
</LinkButton>
|
|
) : (
|
|
<Feedback
|
|
button={<Button variant="primary/medium">Request more…</Button>}
|
|
defaultValue="help"
|
|
/>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
);
|
|
}
|
|
|
|
function CreateDashboardDialog({
|
|
formAction,
|
|
limits,
|
|
}: {
|
|
formAction: string;
|
|
limits: { used: number; limit: number };
|
|
}) {
|
|
const navigation = useNavigation();
|
|
const [title, setTitle] = useState("");
|
|
|
|
const isLoading = navigation.formAction === formAction;
|
|
|
|
return (
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>Create dashboard</DialogHeader>
|
|
<Form method="post" action={formAction} className="space-y-4 pt-3">
|
|
<InputGroup>
|
|
<Label>Title</Label>
|
|
<Input
|
|
name="title"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="My Dashboard"
|
|
required
|
|
/>
|
|
</InputGroup>
|
|
<Paragraph variant="extra-small" className="text-text-dimmed">
|
|
{limits.used}/{limits.limit} dashboards used
|
|
</Paragraph>
|
|
<FormButtons
|
|
confirmButton={
|
|
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
|
|
{isLoading ? "Creating..." : "Create"}
|
|
</Button>
|
|
}
|
|
cancelButton={
|
|
<DialogClose asChild>
|
|
<Button variant="secondary/medium">Cancel</Button>
|
|
</DialogClose>
|
|
}
|
|
/>
|
|
</Form>
|
|
</DialogContent>
|
|
);
|
|
}
|