chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import type { User } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
accountSecurityPath,
|
||||
personalAccessTokensPath,
|
||||
rootPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
|
||||
import { ShieldIcon } from "~/assets/icons/ShieldIcon";
|
||||
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between p-1 transition")}>
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
to={rootPath()}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<span className="text-text-bright">Back to app</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="mb-6 flex grow flex-col overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<SideMenuHeader title="Account" />
|
||||
<SideMenuItem
|
||||
name="Profile"
|
||||
icon={AvatarCircleIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
to={accountPath()}
|
||||
data-action="account"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Personal Access Tokens"
|
||||
icon={ShieldIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
to={personalAccessTokensPath()}
|
||||
data-action="tokens"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Security"
|
||||
icon={PadlockIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
to={accountSecurityPath()}
|
||||
data-action="security"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { IconChartHistogram } from "@tabler/icons-react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import ReactGridLayout from "react-grid-layout";
|
||||
import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon";
|
||||
import { ChartArrowIcon } from "~/assets/icons/ChartArrowIcon";
|
||||
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
|
||||
import { type UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { type RenderIcon } from "~/components/primitives/Icon";
|
||||
import { v3BuiltInDashboardPath, v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
|
||||
import { useReorderableList } from "./useReorderableList";
|
||||
|
||||
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified item for the reorderable dashboards-section list. Combines the two
|
||||
* built-in dashboards (Runs, Agents) and any user-created custom dashboards so
|
||||
* they can be reordered together under the Dashboards parent.
|
||||
*/
|
||||
type DashboardChild =
|
||||
| {
|
||||
key: string;
|
||||
kind: "builtin";
|
||||
label: string;
|
||||
path: string;
|
||||
collapsedIcon: RenderIcon;
|
||||
activeColor: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: "custom";
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export function DashboardList({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
isCollapsed,
|
||||
user,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
isCollapsed: boolean;
|
||||
user: SideMenuUser;
|
||||
}) {
|
||||
const customDashboards = useCustomDashboards();
|
||||
|
||||
const items: DashboardChild[] = [
|
||||
{
|
||||
key: "builtin:overview",
|
||||
kind: "builtin",
|
||||
label: "Run metrics",
|
||||
path: v3BuiltInDashboardPath(organization, project, environment, "overview"),
|
||||
collapsedIcon: ChartArrowIcon,
|
||||
activeColor: "text-runs",
|
||||
},
|
||||
{
|
||||
key: "builtin:llm",
|
||||
kind: "builtin",
|
||||
label: "AI metrics",
|
||||
path: v3BuiltInDashboardPath(organization, project, environment, "llm"),
|
||||
collapsedIcon: AIMetricsIcon,
|
||||
activeColor: "text-aiMetrics",
|
||||
},
|
||||
...customDashboards.map(
|
||||
(d): DashboardChild => ({
|
||||
key: `custom:${d.friendlyId}`,
|
||||
kind: "custom",
|
||||
label: d.title,
|
||||
path: v3CustomDashboardPath(organization, project, environment, d),
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
const initialOrder =
|
||||
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
|
||||
"dashboardChildren"
|
||||
];
|
||||
|
||||
const {
|
||||
orderedItems,
|
||||
layout,
|
||||
containerRef,
|
||||
gridWidth,
|
||||
canReorder,
|
||||
handleDrag,
|
||||
handleDragStop,
|
||||
getIsLast,
|
||||
} = useReorderableList({
|
||||
organizationId: organization.id,
|
||||
listId: "dashboardChildren",
|
||||
items,
|
||||
itemKey: (item) => item.key,
|
||||
initialOrder,
|
||||
isImpersonating: user.isImpersonating,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{canReorder ? (
|
||||
<ReactGridLayout
|
||||
layout={layout}
|
||||
width={gridWidth}
|
||||
gridConfig={{
|
||||
cols: 1,
|
||||
rowHeight: 32,
|
||||
margin: [0, 0] as const,
|
||||
containerPadding: [0, 0] as const,
|
||||
}}
|
||||
resizeConfig={{ enabled: false }}
|
||||
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
|
||||
onDrag={handleDrag}
|
||||
onDragStop={handleDragStop}
|
||||
className="sidebar-reorder-grid"
|
||||
autoSize
|
||||
>
|
||||
{orderedItems.map((item, index) => {
|
||||
const isLast = getIsLast(item.key, index);
|
||||
return (
|
||||
<div key={item.key}>
|
||||
<DashboardChildMenuItem
|
||||
item={item}
|
||||
isCollapsed={isCollapsed}
|
||||
isLast={isLast}
|
||||
showDragHandle
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ReactGridLayout>
|
||||
) : (
|
||||
orderedItems.map((item, index) => (
|
||||
<DashboardChildMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isCollapsed={isCollapsed}
|
||||
isLast={index === orderedItems.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardChildMenuItem({
|
||||
item,
|
||||
isCollapsed,
|
||||
isLast,
|
||||
showDragHandle = false,
|
||||
}: {
|
||||
item: DashboardChild;
|
||||
isCollapsed: boolean;
|
||||
isLast: boolean;
|
||||
showDragHandle?: boolean;
|
||||
}) {
|
||||
const collapsedIcon: RenderIcon =
|
||||
item.kind === "builtin" ? item.collapsedIcon : IconChartHistogram;
|
||||
const expandedIcon: RenderIcon = isLast ? TreeConnectorEnd : TreeConnectorBranch;
|
||||
|
||||
const activeIconColor =
|
||||
item.kind === "builtin"
|
||||
? isCollapsed
|
||||
? item.activeColor
|
||||
: undefined
|
||||
: isCollapsed
|
||||
? "text-text-bright"
|
||||
: undefined;
|
||||
|
||||
const inactiveIconColor = isCollapsed ? "text-text-dimmed" : "text-tertiary";
|
||||
|
||||
return (
|
||||
<SideMenuItem
|
||||
name={item.label}
|
||||
icon={isCollapsed ? collapsedIcon : expandedIcon}
|
||||
activeIconColor={activeIconColor}
|
||||
inactiveIconColor={inactiveIconColor}
|
||||
to={item.path}
|
||||
isCollapsed={isCollapsed}
|
||||
disableIconHover
|
||||
action={
|
||||
showDragHandle ? (
|
||||
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
|
||||
<GripVerticalIcon className="size-3.5" />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
|
||||
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
import { isBranchableEnvironment } from "~/utils/branchableEnvironment";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { useNavigation, useRevalidator } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { branchesPath, branchesDevPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
EnvironmentCombo,
|
||||
EnvironmentIcon,
|
||||
EnvironmentLabel,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "../environments/EnvironmentLabel";
|
||||
import { ButtonContent } from "../primitives/Buttons";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { V4Badge } from "../V4Badge";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
|
||||
export function EnvironmentSelector({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
className,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
className?: string;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const { urlForEnvironment } = useEnvironmentSwitcher();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
// Fetch immediately on open so the list is fresh right away
|
||||
useEffect(() => {
|
||||
if (isMenuOpen && revalidator.state !== "loading") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMenuOpen]);
|
||||
|
||||
const hasStaging = project.environments.some((env) => env.type === "STAGING");
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center rounded pl-1.75 transition-colors hover:bg-background-hover",
|
||||
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
className="text-[0.90625rem] font-medium tracking-[-0.01em]"
|
||||
disableTooltip
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={environmentFullTitle(environment)}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="h-8!"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-56 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
side={isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{project.environments
|
||||
.filter((env) => env.parentEnvironmentId === null)
|
||||
.map((env) => {
|
||||
const renderAsBranchable = isBranchableEnvironment(env);
|
||||
|
||||
if (renderAsBranchable) {
|
||||
const branchEnvironments = project.environments.filter(
|
||||
(e) => e.parentEnvironmentId === env.id
|
||||
);
|
||||
const allBranchEnvironments =
|
||||
env.type === "DEVELOPMENT" ? [env, ...branchEnvironments] : branchEnvironments;
|
||||
return (
|
||||
<Branches
|
||||
key={env.id}
|
||||
parentEnvironment={env}
|
||||
branchEnvironments={allBranchEnvironments}
|
||||
currentEnvironment={environment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={env.id}
|
||||
to={urlForEnvironment(env)}
|
||||
title={<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />}
|
||||
isSelected={env.id === environment.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!hasStaging && isManagedCloud && (
|
||||
<>
|
||||
<PopoverSectionHeader title="Additional environments" />
|
||||
<div className="p-1">
|
||||
<PopoverMenuItem
|
||||
key="staging"
|
||||
to={v3BillingPath(
|
||||
organization,
|
||||
"Upgrade to unlock a Staging environment for your projects."
|
||||
)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<EnvironmentCombo environment={{ type: "STAGING" }} className="text-2sm" />
|
||||
<span className="text-indigo-500">Upgrade</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={false}
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
key="preview"
|
||||
to={v3BillingPath(
|
||||
organization,
|
||||
"Upgrade to unlock Preview environments for your projects."
|
||||
)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<EnvironmentCombo environment={{ type: "PREVIEW" }} className="text-2sm" />
|
||||
<span className="text-indigo-500">Upgrade</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function Branches({
|
||||
parentEnvironment,
|
||||
branchEnvironments,
|
||||
currentEnvironment,
|
||||
}: {
|
||||
parentEnvironment: SideMenuEnvironment;
|
||||
branchEnvironments: SideMenuEnvironment[];
|
||||
currentEnvironment: SideMenuEnvironment;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { urlForEnvironment } = useEnvironmentSwitcher();
|
||||
const navigation = useNavigation();
|
||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Clear timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Small delay before closing to allow moving to the content
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMenuOpen(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null);
|
||||
const state =
|
||||
branchEnvironments.length === 0
|
||||
? "no-branches"
|
||||
: activeBranches.length === 0
|
||||
? "no-active-branches"
|
||||
: "has-branches";
|
||||
|
||||
// Only surface the active environment's archived-branch item in the submenu it
|
||||
// actually belongs to. Both Development and Preview render this component, so
|
||||
// without the parent check an archived dev branch would leak into the Preview
|
||||
// submenu (and vice-versa).
|
||||
const currentBranchIsArchived =
|
||||
environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id;
|
||||
|
||||
const envTextClassName = environmentTextClassName(parentEnvironment);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
|
||||
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className="flex">
|
||||
<PopoverTrigger className="w-full justify-between overflow-hidden focus-custom">
|
||||
<ButtonContent
|
||||
variant="small-menu-item"
|
||||
className="hover:bg-background-hover"
|
||||
TrailingIcon={ChevronRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
textAlignLeft
|
||||
fullWidth
|
||||
>
|
||||
<EnvironmentCombo environment={parentEnvironment} className="mx-auto grow text-2sm" />
|
||||
</ButtonContent>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-64 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
side="right"
|
||||
alignOffset={0}
|
||||
sideOffset={-4}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{currentBranchIsArchived && (
|
||||
<PopoverMenuItem
|
||||
key={environment.id}
|
||||
to={urlForEnvironment(environment)}
|
||||
title={
|
||||
<>
|
||||
<span className={cn("block w-full", envTextClassName)}>
|
||||
{environment.branchName}
|
||||
</span>
|
||||
<Badge variant="extra-small">Archived</Badge>
|
||||
</>
|
||||
}
|
||||
icon={
|
||||
<BranchEnvironmentIconSmall className={cn("size-4 shrink-0", envTextClassName)} />
|
||||
}
|
||||
isSelected={environment.id === currentEnvironment.id}
|
||||
/>
|
||||
)}
|
||||
{state === "has-branches" ? (
|
||||
<>
|
||||
{branchEnvironments
|
||||
.filter((env) => env.archivedAt === null)
|
||||
.map((env) => (
|
||||
<PopoverMenuItem
|
||||
key={env.id}
|
||||
to={urlForEnvironment(env)}
|
||||
title={
|
||||
<span className={cn("block w-full", envTextClassName)}>
|
||||
{env.branchName ?? DEFAULT_DEV_BRANCH}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
<BranchEnvironmentIconSmall
|
||||
className={cn("size-4 shrink-0", envTextClassName)}
|
||||
/>
|
||||
}
|
||||
isSelected={env.id === currentEnvironment.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : state === "no-branches" ? (
|
||||
<div className="flex max-w-sm flex-col gap-1 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<BranchEnvironmentIconSmall className={cn("size-4", envTextClassName)} />
|
||||
<Header2>Create your first branch</Header2>
|
||||
</div>
|
||||
<Paragraph spacing variant="small">
|
||||
Branches are a way to test new features in isolation before merging them into the
|
||||
main environment.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small">
|
||||
Branches are only available when using <V4Badge inline /> or above. Read our{" "}
|
||||
<TextLink to={docsPath("upgrade-to-v4")}>v4 upgrade guide</TextLink> to learn
|
||||
more.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-w-sm flex-col gap-1 p-2">
|
||||
<Paragraph variant="extra-small">All branches are archived.</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-grid-bright p-1">
|
||||
{parentEnvironment.type === "DEVELOPMENT" ? (
|
||||
<PopoverMenuItem
|
||||
to={branchesDevPath(organization, project, environment)}
|
||||
title="Manage dev branches"
|
||||
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
/>
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
to={branchesPath(organization, project, environment)}
|
||||
title="Manage preview branches"
|
||||
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
|
||||
import { motion } from "framer-motion";
|
||||
import { Fragment, useState } from "react";
|
||||
import { BookIcon } from "~/assets/icons/BookIcon";
|
||||
import { BulbIcon } from "~/assets/icons/BulbIcon";
|
||||
import { EnvelopeIcon } from "~/assets/icons/EnvelopeIcon";
|
||||
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
|
||||
import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon";
|
||||
import { StarIcon } from "~/assets/icons/StarIcon";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { sanitizeHttpUrl } from "~/utils/sanitizeUrl";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useRecentChangelogs } from "~/routes/resources.platform-changelogs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Shortcuts } from "../Shortcuts";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
export function HelpAndFeedback({
|
||||
disableShortcut = false,
|
||||
isCollapsed = false,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: {
|
||||
disableShortcut?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
}) {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const _currentPlan = useCurrentPlan();
|
||||
const { changelogs } = useRecentChangelogs(organizationId, projectId);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHelpMenuOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={isCollapsed ? undefined : "flex-1"}
|
||||
>
|
||||
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center gap-1.5 rounded pl-1.75 pr-2 transition-colors hover:bg-background-hover focus-custom",
|
||||
isCollapsed ? "w-full" : "w-full justify-between"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<QuestionMarkIcon className="size-5 min-w-5 shrink-0 text-success" />
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
|
||||
)}
|
||||
>
|
||||
Help & Feedback
|
||||
</span>
|
||||
</span>
|
||||
<ShortcutKey
|
||||
className={cn(
|
||||
"size-4 flex-none transition-all duration-150",
|
||||
isCollapsed ? "hidden" : ""
|
||||
)}
|
||||
shortcut={{ key: "h" }}
|
||||
variant="medium/bright"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Help & Feedback
|
||||
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="h-8! w-full"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-56 divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
side={isCollapsed ? "right" : "top"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon={BookIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<SideMenuItem
|
||||
name="Status"
|
||||
icon={RadarPulseIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
to="https://status.trigger.dev/"
|
||||
data-action="status"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Suggest a feature"
|
||||
icon={BulbIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
to="https://feedback.trigger.dev/"
|
||||
data-action="suggest-a-feature"
|
||||
target="_blank"
|
||||
/>
|
||||
<Shortcuts />
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
className="pl-2"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="pr-1 text-text-dimmed group-hover/button:text-text-bright"
|
||||
data-action="contact-us"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Contact us…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">What's new</Paragraph>
|
||||
{changelogs.map((entry) => (
|
||||
<SideMenuItem
|
||||
key={entry.id}
|
||||
name={entry.title}
|
||||
icon={GrayDotIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
activeIconColor="text-text-dimmed"
|
||||
to={sanitizeHttpUrl(entry.actionUrl) ?? "https://trigger.dev/changelog"}
|
||||
target="_blank"
|
||||
/>
|
||||
))}
|
||||
<SideMenuItem
|
||||
name="Full changelog"
|
||||
icon={StarIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
activeIconColor="text-text-dimmed"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="full-changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrayDotIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center justify-center", className)}>
|
||||
<span className="block h-1.5 w-1.5 rounded-full bg-text-dimmed" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function NotificationCard({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
actionUrl,
|
||||
onDismiss,
|
||||
onCardClick,
|
||||
onLinkClick,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
actionUrl?: string;
|
||||
onDismiss?: () => void;
|
||||
onCardClick?: () => void;
|
||||
onLinkClick?: () => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const descriptionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const check = () => setIsOverflowing(el.scrollHeight - el.clientHeight > 1);
|
||||
check();
|
||||
|
||||
const observer = new ResizeObserver(check);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [description]);
|
||||
|
||||
const handleDismiss = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const handleToggleExpand = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsExpanded((v) => !v);
|
||||
};
|
||||
|
||||
const safeActionUrl = sanitizeUrl(actionUrl);
|
||||
const safeImage = sanitizeUrl(image);
|
||||
|
||||
return (
|
||||
<div className="group/card relative overflow-hidden rounded border border-border-bright bg-background-raised/50 shadow-lg">
|
||||
{safeActionUrl && (
|
||||
<a
|
||||
href={safeActionUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={title}
|
||||
onClick={onCardClick}
|
||||
className="absolute inset-0 z-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-1 px-2.5 pt-2">
|
||||
<p className="flex-1 text-[13px] font-medium leading-normal text-text-bright">{title}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss notification"
|
||||
title="Dismiss notification"
|
||||
className="relative z-20 -mr-1 shrink-0 rounded p-0.5 text-text-dimmed opacity-0 transition group-hover/card:opacity-100 hover:bg-background-raised hover:text-text-bright focus-visible:opacity-100"
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-2.5 pb-2">
|
||||
<div ref={descriptionRef} className={cn(!isExpanded && "line-clamp-3")}>
|
||||
<ReactMarkdown components={getMarkdownComponents(onLinkClick)}>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{(isOverflowing || isExpanded) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleExpand}
|
||||
className="relative z-20 mt-0.5 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
{isExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{safeImage && <img src={safeImage} alt="" className="mt-1.5 rounded" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownComponents(onLinkClick?: () => void) {
|
||||
return {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className="my-0.5 text-xs leading-normal text-text-dimmed">{children}</p>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLinkClick?.();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className="font-semibold text-text-bright">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
|
||||
code: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className="rounded bg-background-raised px-1 py-0.5 text-[11px]">{children}</code>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
|
||||
|
||||
/** Sanitize a URL to prevent XSS via javascript: or data: URIs. Returns "" if invalid. */
|
||||
function sanitizeUrl(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? parsed.href : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import simplur from "simplur";
|
||||
import { NotificationIcon } from "~/assets/icons/NotificationIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { usePlatformNotifications } from "~/routes/resources.platform-notifications";
|
||||
import { NotificationCard } from "./NotificationCard";
|
||||
|
||||
type Notification = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
scope: string;
|
||||
priority: number;
|
||||
payload: {
|
||||
version: string;
|
||||
data: {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
dismissOnAction?: boolean;
|
||||
};
|
||||
};
|
||||
isRead: boolean;
|
||||
};
|
||||
|
||||
export function NotificationPanel({
|
||||
isCollapsed,
|
||||
hasIncident,
|
||||
organizationId,
|
||||
projectId,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
hasIncident: boolean;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { notifications } = usePlatformNotifications(organizationId, projectId) as {
|
||||
notifications: Notification[];
|
||||
};
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
const dismissFetcher = useFetcher();
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const seenFetcher = useFetcher();
|
||||
const clickedIdsRef = useRef<Set<string>>(new Set());
|
||||
const clickFetcher = useFetcher();
|
||||
|
||||
const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id));
|
||||
const notification = visibleNotifications[0] ?? null;
|
||||
|
||||
const handleDismiss = useCallback((id: string) => {
|
||||
setDismissedIds((prev) => new Set(prev).add(id));
|
||||
|
||||
dismissFetcher.submit(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${id}/dismiss`,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
const fireClickBeacon = useCallback((id: string) => {
|
||||
if (clickedIdsRef.current.has(id)) return;
|
||||
clickedIdsRef.current.add(id);
|
||||
|
||||
clickFetcher.submit(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${id}/clicked`,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Fire seen beacon
|
||||
const fireSeenBeacon = useCallback((n: Notification) => {
|
||||
if (seenIdsRef.current.has(n.id)) return;
|
||||
seenIdsRef.current.add(n.id);
|
||||
|
||||
seenFetcher.submit(
|
||||
{},
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/platform-notifications/${n.id}/seen`,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Beacon current notification on mount
|
||||
useEffect(() => {
|
||||
if (notification && !hasIncident) {
|
||||
fireSeenBeacon(notification);
|
||||
}
|
||||
}, [notification?.id, hasIncident]);
|
||||
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data;
|
||||
const card = (
|
||||
<NotificationCard
|
||||
title={title}
|
||||
description={description}
|
||||
image={image}
|
||||
actionUrl={actionUrl}
|
||||
onDismiss={() => handleDismiss(notification.id)}
|
||||
onCardClick={() => {
|
||||
fireClickBeacon(notification.id);
|
||||
if (dismissOnAction) {
|
||||
handleDismiss(notification.id);
|
||||
}
|
||||
}}
|
||||
onLinkClick={() => fireClickBeacon(notification.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<div className={isCollapsed ? "p-1" : "p-2"}>
|
||||
{isCollapsed ? (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
button={
|
||||
<div className="relative">
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="small-menu-item" className="h-8 w-8.75 justify-center">
|
||||
<NotificationIcon className="size-5 text-success" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
}
|
||||
content={simplur`${visibleNotifications.length} notification[|s]`}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : (
|
||||
card
|
||||
)}
|
||||
</div>
|
||||
<PopoverContent side="right" sideOffset={8} align="end" className="w-56 min-w-0! p-0">
|
||||
{card}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { ArrowLeftIcon, LinkIcon } from "@heroicons/react/24/solid";
|
||||
import { BellIcon } from "~/assets/icons/BellIcon";
|
||||
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
|
||||
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
|
||||
import { UsageIcon } from "~/assets/icons/UsageIcon";
|
||||
import { RolesIcon } from "~/assets/icons/RolesIcon";
|
||||
import { SlackIcon } from "~/assets/icons/SlackIcon";
|
||||
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
|
||||
import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
|
||||
import { VercelLogo } from "~/components/integrations/VercelLogo";
|
||||
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationRolesPath,
|
||||
organizationSettingsPath,
|
||||
organizationSlackIntegrationPath,
|
||||
organizationSsoPath,
|
||||
organizationTeamPath,
|
||||
organizationVercelIntegrationPath,
|
||||
rootPath,
|
||||
v3BillingLimitsPath,
|
||||
v3BillingPath,
|
||||
v3PrivateConnectionsPath,
|
||||
v3UsagePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { AskAI } from "../AskAI";
|
||||
|
||||
export type BuildInfo = {
|
||||
appVersion: string | undefined;
|
||||
packageVersion: string;
|
||||
buildTimestampSeconds: string | undefined;
|
||||
gitSha: string | undefined;
|
||||
gitRefName: string | undefined;
|
||||
};
|
||||
|
||||
export function OrganizationSettingsSideMenu({
|
||||
organization,
|
||||
buildInfo,
|
||||
isUsingPlugin,
|
||||
isSsoUsingPlugin,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
buildInfo: BuildInfo;
|
||||
isUsingPlugin: boolean;
|
||||
isSsoUsingPlugin: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
const currentPlan = useCurrentPlan();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const showBuildInfo = isAdmin || !isManagedCloud;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between p-1 transition")}>
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
to={rootPath()}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<span className="text-text-bright">Back to app</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="mb-6 flex grow flex-col gap-4 overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1">
|
||||
<SideMenuHeader title="Organization" />
|
||||
</div>
|
||||
{isManagedCloud && (
|
||||
<>
|
||||
<SideMenuItem
|
||||
name="Usage"
|
||||
icon={UsageIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3UsagePath(organization)}
|
||||
data-action="usage"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Billing"
|
||||
icon={CreditCardIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3BillingPath(organization)}
|
||||
data-action="billing"
|
||||
badge={
|
||||
currentPlan?.v3Subscription?.isPaying ? (
|
||||
<Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{showSelfServe ? (
|
||||
<SideMenuItem
|
||||
name="Billing limits"
|
||||
icon={BellIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3BillingLimitsPath(organization)}
|
||||
data-action="billing-limits"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={organizationTeamPath(organization)}
|
||||
data-action="team"
|
||||
/>
|
||||
{featureFlags.hasPrivateConnections && (
|
||||
<SideMenuItem
|
||||
name="Private Connections"
|
||||
icon={LinkIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3PrivateConnectionsPath(organization)}
|
||||
data-action="private-connections"
|
||||
/>
|
||||
)}
|
||||
{isUsingPlugin && (
|
||||
<SideMenuItem
|
||||
name="Roles"
|
||||
icon={RolesIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={organizationRolesPath(organization)}
|
||||
data-action="roles"
|
||||
/>
|
||||
)}
|
||||
{isSsoUsingPlugin && (
|
||||
<SideMenuItem
|
||||
name="SSO & Directory Sync"
|
||||
icon={PadlockIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={organizationSsoPath(organization)}
|
||||
data-action="sso"
|
||||
badge={
|
||||
currentPlan?.v3Subscription?.plan?.code === "enterprise" ? undefined : (
|
||||
<Badge variant="extra-small">Enterprise</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Settings"
|
||||
icon={SlidersIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1">
|
||||
<SideMenuHeader title="Integrations" />
|
||||
</div>
|
||||
<SideMenuItem
|
||||
name="Vercel"
|
||||
icon={VercelLogo}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
iconClassName="size-4 ml-[0.15rem]"
|
||||
to={organizationVercelIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Slack"
|
||||
icon={SlackIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
iconClassName="size-4 ml-0.5"
|
||||
to={organizationSlackIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="App version" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
{buildInfo.appVersion || `v${buildInfo.packageVersion}`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
{showBuildInfo && buildInfo.buildTimestampSeconds && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="Build timestamp" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
{new Date(Number(buildInfo.buildTimestampSeconds) * 1000).toISOString()}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
{showBuildInfo && buildInfo.gitRefName && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="Git ref" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
<a
|
||||
href={`https://github.com/triggerdotdev/trigger.dev/tree/${buildInfo.gitRefName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-text-bright"
|
||||
>
|
||||
{buildInfo.gitRefName}
|
||||
</a>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
{showBuildInfo && buildInfo.gitSha && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="Git sha" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
<a
|
||||
href={`https://github.com/triggerdotdev/trigger.dev/commit/${buildInfo.gitSha}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-text-bright"
|
||||
>
|
||||
{buildInfo.gitSha.slice(0, 9)}
|
||||
</a>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback organizationId={organization.id} />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SideMenuHeader({
|
||||
title,
|
||||
children,
|
||||
isCollapsed = false,
|
||||
collapsedTitle,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
/** When provided, this text stays visible when collapsed and the rest fades out */
|
||||
collapsedTitle?: string;
|
||||
}) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
// If collapsedTitle is provided and title starts with it, split the title
|
||||
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
|
||||
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
|
||||
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="text-xs whitespace-nowrap">
|
||||
{visiblePart}
|
||||
{fadingPart && (
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{fadingPart}
|
||||
</motion.span>
|
||||
)}
|
||||
</h2>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-text-faint transition group-hover:text-text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type RenderIcon, Icon } from "../primitives/Icon";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
activeIconColor,
|
||||
inactiveIconColor,
|
||||
iconClassName,
|
||||
trailingIcon,
|
||||
trailingIconClassName,
|
||||
name,
|
||||
to,
|
||||
badge,
|
||||
target,
|
||||
isCollapsed = false,
|
||||
action,
|
||||
disableIconHover = false,
|
||||
indented = false,
|
||||
"data-action": dataAction,
|
||||
}: {
|
||||
icon?: RenderIcon;
|
||||
activeIconColor?: string;
|
||||
inactiveIconColor?: string;
|
||||
iconClassName?: string;
|
||||
trailingIcon?: RenderIcon;
|
||||
trailingIconClassName?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
badge?: ReactNode;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
isCollapsed?: boolean;
|
||||
action?: ReactNode;
|
||||
disableIconHover?: boolean;
|
||||
/**
|
||||
* Visually indented variant — same item, just pushed further from
|
||||
* the left edge so it reads as a child of the row above. Used for
|
||||
* grouped sub-items like the Tasks > (Agents / Standard / Scheduled)
|
||||
* cluster. The indent is only applied when the side menu is expanded.
|
||||
*/
|
||||
indented?: boolean;
|
||||
"data-action"?: string;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
const isIndented = indented && !isCollapsed;
|
||||
|
||||
const linkElement = (
|
||||
<Link
|
||||
to={to}
|
||||
target={target}
|
||||
data-action={dataAction}
|
||||
className={cn(
|
||||
"group/menulink flex h-8 items-center gap-2 overflow-hidden rounded pl-1.75 pr-2",
|
||||
isIndented ? "min-w-0 flex-1" : "w-full",
|
||||
isActive
|
||||
? "bg-tertiary text-text-bright"
|
||||
: "text-text-dimmed group-hover/menuitem:bg-background-hover group-hover/menuitem:text-text-bright hover:bg-background-hover hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
isActive ? activeIconColor : (inactiveIconColor ?? "text-text-dimmed"),
|
||||
!isActive &&
|
||||
!disableIconHover &&
|
||||
"group-hover/menuitem:text-text-bright group-hover/menulink:text-text-bright",
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<span className="select-none truncate text-[0.90625rem] font-medium tracking-[-0.01em]">
|
||||
{name}
|
||||
</span>
|
||||
{badge && !isCollapsed && (
|
||||
<motion.div
|
||||
className="ml-1 flex shrink-0 items-center gap-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{badge}
|
||||
</motion.div>
|
||||
)}
|
||||
{trailingIcon && !isCollapsed && (
|
||||
<Icon icon={trailingIcon} className={cn("ml-1 size-4 shrink-0", trailingIconClassName)} />
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const link = isIndented ? (
|
||||
<div className="flex w-full">
|
||||
<div aria-hidden className="w-3 shrink-0" />
|
||||
{linkElement}
|
||||
</div>
|
||||
) : (
|
||||
linkElement
|
||||
);
|
||||
|
||||
if (action) {
|
||||
return (
|
||||
<div className="group/menuitem relative h-8 w-full">
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="h-8! block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-1 right-1 top-1 flex aspect-square items-center justify-center rounded",
|
||||
isActive
|
||||
? "group-hover/menuitem:bg-tertiary"
|
||||
: "group-hover/menuitem:bg-background-hover"
|
||||
)}
|
||||
>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="h-8! block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
initialCollapsed?: boolean;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
/** When true, hides the section header and shows only children */
|
||||
isSideMenuCollapsed?: boolean;
|
||||
itemSpacingClassName?: string;
|
||||
/** Optional action element (e.g., + button) to render on the right side of the header */
|
||||
headerAction?: React.ReactNode;
|
||||
};
|
||||
|
||||
/** A collapsible section for the side menu
|
||||
* The collapsed state is passed in as a prop, and there's a callback when it's toggled so we can save the state.
|
||||
*/
|
||||
export function SideMenuSection({
|
||||
title,
|
||||
initialCollapsed = false,
|
||||
onCollapseToggle,
|
||||
children,
|
||||
isSideMenuCollapsed = false,
|
||||
itemSpacingClassName = "space-y-px",
|
||||
headerAction,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const newIsCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newIsCollapsed);
|
||||
onCollapseToggle?.(newIsCollapsed);
|
||||
}, [isCollapsed, onCollapseToggle]);
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden">
|
||||
{/* Header container - stays in DOM to preserve height */}
|
||||
<div className="relative w-full">
|
||||
{/* Header - fades out when sidebar is collapsed */}
|
||||
<motion.div
|
||||
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-background-hover"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
onClick={isSideMenuCollapsed ? undefined : handleToggle}
|
||||
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
|
||||
<h2 className="whitespace-nowrap text-xs">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
</div>
|
||||
{headerAction && <div className="flex items-center">{headerAction}</div>}
|
||||
</motion.div>
|
||||
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
|
||||
<motion.div
|
||||
className="absolute left-2 right-2 top-1 h-px bg-surface-control"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
className="w-full"
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
exit="collapsed"
|
||||
variants={{
|
||||
expanded: {
|
||||
height: "auto",
|
||||
transition: {
|
||||
height: { duration: 0.3, ease: "easeInOut" },
|
||||
},
|
||||
},
|
||||
collapsed: {
|
||||
height: 0,
|
||||
transition: {
|
||||
height: { duration: 0.2, ease: "easeInOut" },
|
||||
},
|
||||
},
|
||||
}}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<motion.div
|
||||
className={`w-full ${itemSpacingClassName}`}
|
||||
variants={{
|
||||
expanded: {
|
||||
translateY: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3, ease: "easeInOut" },
|
||||
},
|
||||
collapsed: {
|
||||
translateY: "-100%",
|
||||
opacity: 0,
|
||||
transition: { duration: 0.2, ease: "easeInOut" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
|
||||
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
|
||||
export function TreeConnectorBranch({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeConnectorEnd({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Valid section IDs that can have their collapsed state toggled
|
||||
export const SideMenuSectionIdSchema = z.enum([
|
||||
"ai",
|
||||
"manage",
|
||||
"metrics",
|
||||
"deployments",
|
||||
"project-settings",
|
||||
"tasks",
|
||||
]);
|
||||
|
||||
// Inferred type from the schema
|
||||
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Layout, useContainerWidth } from "react-grid-layout";
|
||||
|
||||
/**
|
||||
* Generic hook for managing a reorderable list in the side menu.
|
||||
*
|
||||
* Handles order state, sorting, grid layout, drag callbacks, and persistence
|
||||
* via the `/resources/preferences/sidemenu` resource route.
|
||||
*
|
||||
* @param organizationId - Organization ID for scoping the persisted order
|
||||
* @param listId - Identifier for this list (e.g. "customDashboards")
|
||||
* @param items - The items to reorder
|
||||
* @param itemKey - Extract a stable string key from each item
|
||||
* @param initialOrder - Initial order from stored preferences (if any)
|
||||
* @param isImpersonating - Skip persistence when impersonating
|
||||
*/
|
||||
export function useReorderableList<T>({
|
||||
organizationId,
|
||||
listId,
|
||||
items,
|
||||
itemKey,
|
||||
initialOrder,
|
||||
isImpersonating,
|
||||
}: {
|
||||
organizationId: string;
|
||||
listId: string;
|
||||
items: T[];
|
||||
itemKey: (item: T) => string;
|
||||
initialOrder: string[] | undefined;
|
||||
isImpersonating: boolean;
|
||||
}) {
|
||||
const orderFetcher = useFetcher();
|
||||
|
||||
const [order, setOrder] = useState<string[]>(() => initialOrder ?? items.map(itemKey));
|
||||
|
||||
// Sync order when organizationId changes (component may not remount)
|
||||
useEffect(() => {
|
||||
setOrder(initialOrder ?? items.map(itemKey));
|
||||
}, [organizationId]);
|
||||
|
||||
// Sort items by stored order, new items go to end
|
||||
const orderedItems = useMemo(() => {
|
||||
const orderMap = new Map(order.map((id, i) => [id, i]));
|
||||
return [...items].sort((a, b) => {
|
||||
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
|
||||
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
}, [items, order, itemKey]);
|
||||
|
||||
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
orderedItems.map((item, i) => ({
|
||||
i: itemKey(item),
|
||||
x: 0,
|
||||
y: i,
|
||||
w: 1,
|
||||
h: 1,
|
||||
})),
|
||||
[orderedItems, itemKey]
|
||||
);
|
||||
|
||||
// Width measurement for ReactGridLayout
|
||||
const {
|
||||
width: gridWidth,
|
||||
containerRef,
|
||||
mounted: gridMounted,
|
||||
} = useContainerWidth({ initialWidth: 216 });
|
||||
|
||||
const canReorder = orderedItems.length >= 2;
|
||||
|
||||
// Track layout during drag for real-time visual updates
|
||||
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
|
||||
|
||||
const handleDrag = useCallback((layout: Layout) => {
|
||||
setDragLayout(layout);
|
||||
}, []);
|
||||
|
||||
// Handle drag stop - extract new order from layout y-positions
|
||||
const handleDragStop = useCallback(
|
||||
(layout: Layout) => {
|
||||
setDragLayout(null);
|
||||
const sorted = [...layout].sort((a, b) => a.y - b.y);
|
||||
const newOrder = sorted.map((item) => item.i);
|
||||
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
|
||||
setOrder(newOrder);
|
||||
// Persist immediately
|
||||
if (!isImpersonating) {
|
||||
const formData = new FormData();
|
||||
formData.append("organizationId", organizationId);
|
||||
formData.append("listId", listId);
|
||||
formData.append("itemOrder", JSON.stringify(newOrder));
|
||||
orderFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/resources/preferences/sidemenu",
|
||||
});
|
||||
}
|
||||
},
|
||||
[order, organizationId, listId, isImpersonating, orderFetcher]
|
||||
);
|
||||
|
||||
// Compute which item is visually last (during drag or at rest)
|
||||
const getIsLast = useCallback(
|
||||
(key: string, index: number) => {
|
||||
if (dragLayout) {
|
||||
const maxY = Math.max(...dragLayout.map((l) => l.y));
|
||||
return dragLayout.find((l) => l.i === key)?.y === maxY;
|
||||
}
|
||||
return index === orderedItems.length - 1;
|
||||
},
|
||||
[dragLayout, orderedItems.length]
|
||||
);
|
||||
|
||||
return {
|
||||
orderedItems,
|
||||
layout,
|
||||
containerRef: containerRef as Ref<HTMLDivElement>,
|
||||
gridWidth,
|
||||
gridMounted,
|
||||
canReorder,
|
||||
handleDrag,
|
||||
handleDragStop,
|
||||
getIsLast,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user