Skip to content

Commit e9fe1d0

Browse files
committed
feat(webapp): make the side menu env switcher a segmented control
The side menu environment switcher is now a segmented control (Dev, Staging, Prev, Prod) instead of a dropdown, with each segment tinted in its environment color when active. The Dev segment shows the local dev server connection as a small pulsing dot with a hover tooltip, replacing the old connection button and modal. Preview opens a popover to switch branches and shows the active branch name. When the side menu is collapsed it falls back to the original dropdown selector, which is also still used on the blank-state panels and the Limits page.
1 parent 3aa2886 commit e9fe1d0

3 files changed

Lines changed: 378 additions & 187 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { Link, useNavigation } from "@remix-run/react";
2+
import { motion } from "framer-motion";
3+
import { useEffect, useState } from "react";
4+
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
5+
import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher";
6+
import { useFeatures } from "~/hooks/useFeatures";
7+
import { type MatchedOrganization } from "~/hooks/useOrganizations";
8+
import { cn } from "~/utils/cn";
9+
import { v3BillingPath } from "~/utils/pathBuilder";
10+
import { useDevPresence } from "../DevPresence";
11+
import { environmentTextClassName } from "../environments/EnvironmentLabel";
12+
import {
13+
Popover,
14+
PopoverContent,
15+
PopoverSectionHeader,
16+
PopoverTrigger,
17+
} from "../primitives/Popover";
18+
import { SimpleTooltip } from "../primitives/Tooltip";
19+
import { BranchesPopoverContent, EnvironmentSelector } from "./EnvironmentSelector";
20+
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
21+
22+
type EnvType = "DEVELOPMENT" | "STAGING" | "PREVIEW" | "PRODUCTION";
23+
24+
const SEGMENTS: { type: EnvType; label: string }[] = [
25+
{ type: "DEVELOPMENT", label: "Dev" },
26+
{ type: "STAGING", label: "Staging" },
27+
{ type: "PREVIEW", label: "Prev" },
28+
{ type: "PRODUCTION", label: "Prod" },
29+
];
30+
31+
// Upgrade copy mirrors the dropdown's "Additional environments" prompts.
32+
const UPGRADE_MESSAGE: Partial<Record<EnvType, string>> = {
33+
STAGING: "Upgrade to unlock a Staging environment for your projects.",
34+
PREVIEW: "Upgrade to unlock Preview environments for your projects.",
35+
};
36+
37+
const PILL_LAYOUT_ID = "env-segmented-pill";
38+
39+
// The sliding selected pill is tinted with the env's own color.
40+
const ENV_PILL: Record<EnvType, string> = {
41+
DEVELOPMENT: "border-dev/30 bg-dev/15",
42+
STAGING: "border-staging/30 bg-staging/15",
43+
PREVIEW: "border-preview/30 bg-preview/15",
44+
PRODUCTION: "border-prod/30 bg-prod/15",
45+
};
46+
47+
const SEGMENT_CLASS =
48+
"group relative flex h-full grow items-center justify-center gap-1 rounded px-1.5 text-xs font-medium outline-none focus-custom";
49+
50+
/**
51+
* Side-menu-only environment switcher rendered as a segmented control (Dev /
52+
* Staging / Prev / Prod) instead of the dropdown `EnvironmentSelector`. The
53+
* dropdown is still used on blank-state panels and the Limits page.
54+
*/
55+
export function EnvironmentSegmentedControl({
56+
organization,
57+
project,
58+
environment,
59+
isCollapsed = false,
60+
}: {
61+
organization: MatchedOrganization;
62+
project: SideMenuProject;
63+
environment: SideMenuEnvironment;
64+
isCollapsed?: boolean;
65+
}) {
66+
const { urlForEnvironment } = useEnvironmentSwitcher();
67+
const { isManagedCloud } = useFeatures();
68+
const { isConnected } = useDevPresence();
69+
const navigation = useNavigation();
70+
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
71+
72+
useEffect(() => {
73+
setIsPreviewOpen(false);
74+
}, [navigation.location?.pathname]);
75+
76+
const rootEnvFor = (type: EnvType) =>
77+
project.environments.find((e) => e.type === type && e.parentEnvironmentId === null);
78+
79+
// Which segment is active. A branch (e.g. a preview branch) resolves to its
80+
// parent env's type, so the parent segment shows as active.
81+
const activeType: EnvType = (() => {
82+
if (environment.parentEnvironmentId) {
83+
const parent = project.environments.find((e) => e.id === environment.parentEnvironmentId);
84+
if (parent) return parent.type as EnvType;
85+
}
86+
return environment.type as EnvType;
87+
})();
88+
89+
// While the Preview popover is open, show Preview as the selected segment even
90+
// if we haven't navigated into a preview branch yet.
91+
const selectedType: EnvType = isPreviewOpen ? "PREVIEW" : activeType;
92+
93+
// The dev connection is only tracked while you're in the dev environment (the
94+
// presence provider is enabled there), matching the old connection button.
95+
const showDevDot = environment.type === "DEVELOPMENT" && project.engine === "V2";
96+
97+
// When collapsed there's no room for the segmented control, so fall back to the
98+
// original dropdown selector (env icon + menu).
99+
if (isCollapsed) {
100+
return (
101+
<EnvironmentSelector
102+
organization={organization}
103+
project={project}
104+
environment={environment}
105+
isCollapsed
106+
className="w-full"
107+
/>
108+
);
109+
}
110+
111+
return (
112+
<div className="flex h-8 w-full items-center gap-x-0.5 rounded bg-charcoal-750 p-0.5">
113+
{SEGMENTS.map((segment) => {
114+
const env = rootEnvFor(segment.type);
115+
const isSelected = selectedType === segment.type;
116+
const colorClass = isSelected
117+
? environmentTextClassName({ type: segment.type })
118+
: "text-text-dimmed transition group-hover:text-text-bright";
119+
120+
const pill = isSelected ? (
121+
<motion.div
122+
layoutId={PILL_LAYOUT_ID}
123+
transition={{ duration: 0.4, type: "spring" }}
124+
className={cn("absolute inset-0 rounded border", ENV_PILL[segment.type])}
125+
/>
126+
) : null;
127+
128+
// Preview: opens the branch switcher popover on click (active or not).
129+
if (segment.type === "PREVIEW" && env) {
130+
const branchEnvironments = project.environments.filter(
131+
(e) => e.parentEnvironmentId === env.id
132+
);
133+
// When the active environment is a preview branch, show the branch name
134+
// truncated to 4 characters (plus an ellipsis) instead of "Prev".
135+
const branchName =
136+
environment.parentEnvironmentId === env.id ? environment.branchName : null;
137+
const previewLabel =
138+
branchName && branchName.length > 4
139+
? `${branchName.slice(0, 4)}…`
140+
: (branchName ?? segment.label);
141+
return (
142+
<Popover key={segment.type} open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
143+
<PopoverTrigger className={SEGMENT_CLASS}>
144+
{pill}
145+
<span className={cn("relative z-10 truncate", colorClass)}>{previewLabel}</span>
146+
<DropdownIcon className={cn("relative z-10 size-3.5 shrink-0", colorClass)} />
147+
</PopoverTrigger>
148+
<PopoverContent
149+
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
150+
align="start"
151+
side="bottom"
152+
sideOffset={6}
153+
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
154+
>
155+
<PopoverSectionHeader title="Preview environments" />
156+
<BranchesPopoverContent
157+
parentEnvironment={env}
158+
branchEnvironments={branchEnvironments}
159+
currentEnvironment={environment}
160+
/>
161+
</PopoverContent>
162+
</Popover>
163+
);
164+
}
165+
166+
// Missing env (Staging/Preview not provisioned): upgrade link on managed
167+
// cloud, otherwise a disabled segment.
168+
if (!env) {
169+
const upgradeMessage = UPGRADE_MESSAGE[segment.type];
170+
if (isManagedCloud && upgradeMessage) {
171+
return (
172+
<Link
173+
key={segment.type}
174+
to={v3BillingPath(organization, upgradeMessage)}
175+
className={SEGMENT_CLASS}
176+
>
177+
<span className="relative z-10 truncate text-text-dimmed transition group-hover:text-text-bright">
178+
{segment.label}
179+
</span>
180+
</Link>
181+
);
182+
}
183+
return (
184+
<div key={segment.type} className={cn(SEGMENT_CLASS, "cursor-default")} aria-disabled>
185+
<span className="relative z-10 truncate text-charcoal-600">{segment.label}</span>
186+
</div>
187+
);
188+
}
189+
190+
// Dev / Staging / Prod: navigate to the environment.
191+
return (
192+
<Link key={segment.type} to={urlForEnvironment(env)} className={SEGMENT_CLASS}>
193+
{pill}
194+
<span className={cn("relative z-10 truncate", colorClass)}>{segment.label}</span>
195+
{segment.type === "DEVELOPMENT" && showDevDot ? (
196+
<DevConnectionDot isConnected={isConnected} />
197+
) : null}
198+
</Link>
199+
);
200+
})}
201+
</div>
202+
);
203+
}
204+
205+
/**
206+
* Small status dot (top-right of the Dev segment) replacing the old connection
207+
* button: a pulsing green dot when connected, a static grey dot otherwise. Hover
208+
* (after a short delay) reveals the connection status as a tooltip.
209+
*/
210+
function DevConnectionDot({ isConnected }: { isConnected: boolean | undefined }) {
211+
const content =
212+
isConnected === undefined
213+
? "Checking connection…"
214+
: isConnected
215+
? "Your dev server is connected"
216+
: "Your dev server is not connected";
217+
218+
return (
219+
<SimpleTooltip
220+
asChild
221+
disableHoverableContent
222+
delayDuration={500}
223+
side="right"
224+
sideOffset={8}
225+
content={content}
226+
button={
227+
<span className="absolute right-1 top-1 z-20 flex size-1.5">
228+
{isConnected ? (
229+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-75" />
230+
) : null}
231+
<span
232+
className={cn(
233+
"relative inline-flex size-1.5 rounded-full",
234+
isConnected ? "bg-success" : "bg-charcoal-500"
235+
)}
236+
/>
237+
</span>
238+
}
239+
/>
240+
);
241+
}

0 commit comments

Comments
 (0)