Skip to content

Commit 30aa288

Browse files
committed
feat: add environment management features including switcher, dialog, and production guard
- Implemented EnvironmentSwitcher component for selecting environments. - Added NewEnvironmentDialog for provisioning new environments. - Created ProductionGuard to confirm destructive actions in production environments. - Developed hooks for managing environment state and details. - Established routes for environment overview and management. - Enhanced UI components for better user experience in environment management.
1 parent b78b426 commit 30aa288

13 files changed

+1598
-17
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* EnvironmentBadge — color-coded pill indicating the environment type.
5+
*
6+
* Color grammar follows industry convention (Salesforce/Power Platform):
7+
* - `production` → red/destructive (danger zone)
8+
* - `staging` → amber (pre-prod parity)
9+
* - `sandbox` → amber/yellow (prod clone, but not live)
10+
* - `development`, `test`, `preview`, `trial` → muted/secondary
11+
*
12+
* Keep this component purely presentational — no data fetching or
13+
* navigation side-effects — so it can be rendered in tables, badges,
14+
* breadcrumbs, and dialogs without pulling in context.
15+
*/
16+
17+
import { cn } from '@/lib/utils';
18+
import type { EnvironmentType } from '@objectstack/spec/cloud';
19+
20+
const VARIANT: Record<EnvironmentType, string> = {
21+
production:
22+
'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300',
23+
staging:
24+
'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
25+
sandbox:
26+
'border-yellow-500/40 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300',
27+
development: 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300',
28+
test: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
29+
preview:
30+
'border-violet-500/40 bg-violet-500/10 text-violet-700 dark:text-violet-300',
31+
trial: 'border-muted bg-muted text-muted-foreground',
32+
};
33+
34+
const SHORT: Record<EnvironmentType, string> = {
35+
production: 'PROD',
36+
staging: 'STG',
37+
sandbox: 'SBX',
38+
development: 'DEV',
39+
test: 'TEST',
40+
preview: 'PRE',
41+
trial: 'TRIAL',
42+
};
43+
44+
export interface EnvironmentBadgeProps {
45+
envType: EnvironmentType;
46+
/** Use the full label instead of the 3–4 char short form. */
47+
full?: boolean;
48+
className?: string;
49+
}
50+
51+
export function EnvironmentBadge({
52+
envType,
53+
full,
54+
className,
55+
}: EnvironmentBadgeProps) {
56+
return (
57+
<span
58+
className={cn(
59+
'inline-flex h-4 items-center rounded border px-1.5 text-[10px] font-mono font-semibold uppercase tracking-wider',
60+
VARIANT[envType],
61+
className,
62+
)}
63+
title={envType}
64+
>
65+
{full ? envType : SHORT[envType]}
66+
</span>
67+
);
68+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* EnvironmentSwitcher
5+
*
6+
* Power Platform-style environment picker, anchored on the left of the
7+
* site header. Lists every environment visible to the current session,
8+
* grouped by `envType`, and navigates to `/environments/:id/overview`
9+
* when one is selected. Also exposes a "+ New environment…" footer that
10+
* opens {@link NewEnvironmentDialog}.
11+
*
12+
* The switcher is intentionally stateless with respect to the active
13+
* environment — the URL is the source of truth, read via
14+
* `useParams({ strict: false })`. Persistence across reloads is handled
15+
* by `rememberActiveEnvironment()` in `useEnvironments` and is replayed
16+
* by `useObjectStackClient` during initial client construction.
17+
*
18+
* @see docs/adr/0002-environment-database-isolation.md
19+
*/
20+
21+
import { useMemo, useState } from 'react';
22+
import { useNavigate, useParams } from '@tanstack/react-router';
23+
import { ChevronsUpDown, Layers, Plus, Search, Check } from 'lucide-react';
24+
import type { Environment, EnvironmentType } from '@objectstack/spec/cloud';
25+
import {
26+
DropdownMenu,
27+
DropdownMenuContent,
28+
DropdownMenuItem,
29+
DropdownMenuLabel,
30+
DropdownMenuSeparator,
31+
DropdownMenuTrigger,
32+
} from '@/components/ui/dropdown-menu';
33+
import { Badge } from '@/components/ui/badge';
34+
import { Button } from '@/components/ui/button';
35+
import { Input } from '@/components/ui/input';
36+
import { useEnvironments } from '@/hooks/useEnvironments';
37+
import { NewEnvironmentDialog } from '@/components/new-environment-dialog';
38+
import { EnvironmentBadge } from '@/components/environment-badge';
39+
40+
const ENV_TYPE_ORDER: EnvironmentType[] = [
41+
'production',
42+
'staging',
43+
'sandbox',
44+
'development',
45+
'test',
46+
'preview',
47+
'trial',
48+
];
49+
50+
const ENV_TYPE_LABEL: Record<EnvironmentType, string> = {
51+
production: 'Production',
52+
staging: 'Staging',
53+
sandbox: 'Sandbox',
54+
development: 'Development',
55+
test: 'Test',
56+
preview: 'Preview',
57+
trial: 'Trial',
58+
};
59+
60+
export function EnvironmentSwitcher() {
61+
const navigate = useNavigate();
62+
const params = useParams({ strict: false }) as { environmentId?: string };
63+
const activeId = params.environmentId;
64+
const { environments, loading, reload } = useEnvironments();
65+
const [open, setOpen] = useState(false);
66+
const [search, setSearch] = useState('');
67+
const [createOpen, setCreateOpen] = useState(false);
68+
69+
const active = useMemo(
70+
() => environments.find((e) => e.id === activeId) ?? null,
71+
[environments, activeId],
72+
);
73+
74+
const grouped = useMemo(() => {
75+
const q = search.trim().toLowerCase();
76+
const filtered = q
77+
? environments.filter(
78+
(e) =>
79+
e.slug.toLowerCase().includes(q) ||
80+
e.displayName.toLowerCase().includes(q) ||
81+
e.id.toLowerCase().includes(q),
82+
)
83+
: environments;
84+
const map = new Map<EnvironmentType, Environment[]>();
85+
for (const e of filtered) {
86+
const arr = map.get(e.envType) ?? [];
87+
arr.push(e);
88+
map.set(e.envType, arr);
89+
}
90+
return ENV_TYPE_ORDER.filter((t) => map.has(t)).map(
91+
(t) => [t, map.get(t)!] as const,
92+
);
93+
}, [environments, search]);
94+
95+
const handleSelect = (id: string) => {
96+
setOpen(false);
97+
setSearch('');
98+
navigate({
99+
to: '/environments/$environmentId',
100+
params: { environmentId: id },
101+
});
102+
};
103+
104+
return (
105+
<>
106+
<DropdownMenu open={open} onOpenChange={setOpen}>
107+
<DropdownMenuTrigger asChild>
108+
<Button
109+
variant="ghost"
110+
size="sm"
111+
className="h-8 gap-2 px-2 text-sm font-medium"
112+
>
113+
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
114+
{active ? (
115+
<>
116+
<span className="max-w-[160px] truncate">
117+
{active.displayName}
118+
</span>
119+
<EnvironmentBadge envType={active.envType} />
120+
</>
121+
) : (
122+
<span className="text-muted-foreground">
123+
{loading ? 'Loading environments…' : 'Select environment'}
124+
</span>
125+
)}
126+
<ChevronsUpDown className="h-3.5 w-3.5 opacity-60" />
127+
</Button>
128+
</DropdownMenuTrigger>
129+
<DropdownMenuContent
130+
align="start"
131+
className="w-[340px] p-0"
132+
sideOffset={4}
133+
>
134+
<div className="p-2 border-b">
135+
<div className="relative">
136+
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
137+
<Input
138+
autoFocus
139+
placeholder="Search environments…"
140+
value={search}
141+
onChange={(e) => setSearch(e.target.value)}
142+
className="h-8 pl-7 text-sm"
143+
/>
144+
</div>
145+
</div>
146+
147+
<div className="max-h-[340px] overflow-y-auto py-1">
148+
{environments.length === 0 && !loading && (
149+
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
150+
No environments yet.
151+
</div>
152+
)}
153+
{grouped.map(([envType, list]) => (
154+
<div key={envType}>
155+
<DropdownMenuLabel className="text-[10px] uppercase tracking-wider text-muted-foreground">
156+
{ENV_TYPE_LABEL[envType]}
157+
</DropdownMenuLabel>
158+
{list.map((env) => (
159+
<DropdownMenuItem
160+
key={env.id}
161+
onSelect={(e) => {
162+
e.preventDefault();
163+
handleSelect(env.id);
164+
}}
165+
className="flex items-start gap-2"
166+
>
167+
<div className="flex-1 min-w-0">
168+
<div className="flex items-center gap-2">
169+
<span className="truncate font-medium">
170+
{env.displayName}
171+
</span>
172+
{env.isDefault && (
173+
<Badge
174+
variant="outline"
175+
className="h-4 px-1 text-[9px]"
176+
>
177+
default
178+
</Badge>
179+
)}
180+
</div>
181+
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
182+
<code className="font-mono">{env.slug}</code>
183+
{env.region && <span>· {env.region}</span>}
184+
<span>· {env.status}</span>
185+
</div>
186+
</div>
187+
<EnvironmentBadge envType={env.envType} />
188+
{env.id === activeId && (
189+
<Check className="h-3.5 w-3.5 text-primary" />
190+
)}
191+
</DropdownMenuItem>
192+
))}
193+
</div>
194+
))}
195+
</div>
196+
197+
<DropdownMenuSeparator className="m-0" />
198+
<DropdownMenuItem
199+
onSelect={(e) => {
200+
e.preventDefault();
201+
setOpen(false);
202+
setCreateOpen(true);
203+
}}
204+
className="gap-2 text-sm"
205+
>
206+
<Plus className="h-3.5 w-3.5" />
207+
New environment…
208+
</DropdownMenuItem>
209+
<DropdownMenuItem
210+
onSelect={(e) => {
211+
e.preventDefault();
212+
setOpen(false);
213+
navigate({ to: '/environments' });
214+
}}
215+
className="gap-2 text-sm text-muted-foreground"
216+
>
217+
Manage all environments
218+
</DropdownMenuItem>
219+
</DropdownMenuContent>
220+
</DropdownMenu>
221+
222+
<NewEnvironmentDialog
223+
open={createOpen}
224+
onOpenChange={setCreateOpen}
225+
onCreated={async (env) => {
226+
await reload();
227+
navigate({
228+
to: '/environments/$environmentId',
229+
params: { environmentId: env.id },
230+
});
231+
}}
232+
/>
233+
</>
234+
);
235+
}

0 commit comments

Comments
 (0)