Skip to content

Commit d4db4f6

Browse files
committed
feat: add FeatureFlagsDialog component for org flag editing
1 parent 030d06b commit d4db4f6

1 file changed

Lines changed: 312 additions & 0 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import { useCallback, useEffect, useMemo, useState } from "react";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogHeader,
7+
DialogTitle,
8+
DialogDescription,
9+
DialogFooter,
10+
} from "~/components/primitives/Dialog";
11+
import { Button } from "~/components/primitives/Buttons";
12+
import { Switch } from "~/components/primitives/Switch";
13+
import { Select, SelectItem } from "~/components/primitives/Select";
14+
import { Input } from "~/components/primitives/Input";
15+
import { cn } from "~/utils/cn";
16+
import type { FlagControlType } from "~/v3/featureFlags.server";
17+
18+
type LoaderData = {
19+
org: { id: string; title: string; slug: string };
20+
orgFlags: Record<string, unknown>;
21+
globalFlags: Record<string, unknown>;
22+
controlTypes: Record<string, FlagControlType>;
23+
};
24+
25+
type ActionData = {
26+
success?: boolean;
27+
error?: string;
28+
};
29+
30+
type FeatureFlagsDialogProps = {
31+
orgId: string | null;
32+
orgTitle: string;
33+
open: boolean;
34+
onOpenChange: (open: boolean) => void;
35+
};
36+
37+
export function FeatureFlagsDialog({
38+
orgId,
39+
orgTitle,
40+
open,
41+
onOpenChange,
42+
}: FeatureFlagsDialogProps) {
43+
const loadFetcher = useFetcher<LoaderData>();
44+
const saveFetcher = useFetcher<ActionData>();
45+
46+
// Local state for edits - keyed by flag name, value is the override or undefined (unset)
47+
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
48+
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
49+
50+
// Load flags when dialog opens
51+
useEffect(() => {
52+
if (open && orgId) {
53+
loadFetcher.load(`/admin/api/orgs/${orgId}/feature-flags`);
54+
}
55+
}, [open, orgId]);
56+
57+
// Sync loaded data into local state
58+
useEffect(() => {
59+
if (loadFetcher.data) {
60+
const loaded = loadFetcher.data.orgFlags ?? {};
61+
setOverrides({ ...loaded });
62+
setInitialOverrides({ ...loaded });
63+
}
64+
}, [loadFetcher.data]);
65+
66+
// Close on successful save
67+
useEffect(() => {
68+
if (saveFetcher.data?.success) {
69+
onOpenChange(false);
70+
}
71+
}, [saveFetcher.data]);
72+
73+
const isDirty = useMemo(() => {
74+
return JSON.stringify(overrides) !== JSON.stringify(initialOverrides);
75+
}, [overrides, initialOverrides]);
76+
77+
const setFlagValue = useCallback((key: string, value: unknown) => {
78+
setOverrides((prev) => ({ ...prev, [key]: value }));
79+
}, []);
80+
81+
const unsetFlag = useCallback((key: string) => {
82+
setOverrides((prev) => {
83+
const next = { ...prev };
84+
delete next[key];
85+
return next;
86+
});
87+
}, []);
88+
89+
const handleSave = useCallback(() => {
90+
if (!orgId) return;
91+
92+
const body = Object.keys(overrides).length === 0 ? null : overrides;
93+
94+
saveFetcher.submit(JSON.stringify(body), {
95+
method: "POST",
96+
action: `/admin/api/orgs/${orgId}/feature-flags`,
97+
encType: "application/json",
98+
});
99+
}, [orgId, overrides, saveFetcher]);
100+
101+
const data = loadFetcher.data;
102+
const isLoading = loadFetcher.state === "loading";
103+
const isSaving = saveFetcher.state === "submitting";
104+
105+
// Build JSON preview
106+
const jsonPreview = useMemo(() => {
107+
if (Object.keys(overrides).length === 0) return "null";
108+
return JSON.stringify(overrides, null, 2);
109+
}, [overrides]);
110+
111+
// Sort flag keys alphabetically
112+
const sortedFlagKeys = useMemo(() => {
113+
if (!data) return [];
114+
return Object.keys(data.controlTypes).sort();
115+
}, [data]);
116+
117+
return (
118+
<Dialog open={open} onOpenChange={onOpenChange}>
119+
<DialogContent className="sm:max-w-lg">
120+
<DialogHeader>
121+
<DialogTitle>Feature Flags - {orgTitle}</DialogTitle>
122+
<DialogDescription>
123+
Org-level overrides. Unset flags inherit from global defaults.
124+
</DialogDescription>
125+
</DialogHeader>
126+
127+
<div className="max-h-[60vh] overflow-y-auto">
128+
{isLoading ? (
129+
<div className="py-8 text-center text-sm text-text-dimmed">Loading flags...</div>
130+
) : data ? (
131+
<div className="flex flex-col gap-1.5">
132+
{sortedFlagKeys.map((key) => {
133+
const control = data.controlTypes[key];
134+
const isOverridden = key in overrides;
135+
const globalValue = data.globalFlags[key as keyof typeof data.globalFlags];
136+
const globalDisplay =
137+
globalValue !== undefined ? String(globalValue) : "unset";
138+
139+
return (
140+
<div
141+
key={key}
142+
className={cn(
143+
"flex items-center justify-between rounded-md px-3 py-2.5",
144+
isOverridden
145+
? "border border-indigo-500/20 bg-indigo-500/5"
146+
: "bg-charcoal-750"
147+
)}
148+
>
149+
<div className="min-w-0 flex-1">
150+
<div
151+
className={cn(
152+
"truncate text-sm",
153+
isOverridden ? "text-text-bright" : "text-text-dimmed"
154+
)}
155+
>
156+
{key}
157+
</div>
158+
<div className="text-xs text-charcoal-400">global: {globalDisplay}</div>
159+
</div>
160+
161+
<div className="flex items-center gap-2">
162+
{isOverridden && (
163+
<Button variant="minimal/small" onClick={() => unsetFlag(key)}>
164+
unset
165+
</Button>
166+
)}
167+
168+
{control.type === "boolean" && (
169+
<BooleanControl
170+
value={isOverridden ? (overrides[key] as boolean) : undefined}
171+
onChange={(val) => setFlagValue(key, val)}
172+
dimmed={!isOverridden}
173+
/>
174+
)}
175+
176+
{control.type === "enum" && (
177+
<EnumControl
178+
value={isOverridden ? (overrides[key] as string) : undefined}
179+
options={control.options}
180+
onChange={(val) => {
181+
if (val === "__unset__") {
182+
unsetFlag(key);
183+
} else {
184+
setFlagValue(key, val);
185+
}
186+
}}
187+
dimmed={!isOverridden}
188+
/>
189+
)}
190+
191+
{control.type === "string" && (
192+
<StringControl
193+
value={isOverridden ? (overrides[key] as string) : ""}
194+
onChange={(val) => {
195+
if (val === "") {
196+
unsetFlag(key);
197+
} else {
198+
setFlagValue(key, val);
199+
}
200+
}}
201+
dimmed={!isOverridden}
202+
/>
203+
)}
204+
</div>
205+
</div>
206+
);
207+
})}
208+
</div>
209+
) : null}
210+
</div>
211+
212+
{/* JSON Preview */}
213+
{data && (
214+
<details className="mt-2">
215+
<summary className="cursor-pointer text-xs text-text-dimmed hover:text-text-bright">
216+
Preview JSON
217+
</summary>
218+
<pre className="mt-1 max-h-40 overflow-auto rounded bg-charcoal-800 p-2 text-xs text-text-dimmed">
219+
{jsonPreview}
220+
</pre>
221+
</details>
222+
)}
223+
224+
<DialogFooter>
225+
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
226+
Cancel
227+
</Button>
228+
<Button
229+
variant="primary/small"
230+
onClick={handleSave}
231+
disabled={!isDirty || isSaving}
232+
>
233+
{isSaving ? "Saving..." : "Save Changes"}
234+
</Button>
235+
</DialogFooter>
236+
</DialogContent>
237+
</Dialog>
238+
);
239+
}
240+
241+
// --- Sub-components ---
242+
243+
function BooleanControl({
244+
value,
245+
onChange,
246+
dimmed,
247+
}: {
248+
value: boolean | undefined;
249+
onChange: (val: boolean) => void;
250+
dimmed: boolean;
251+
}) {
252+
return (
253+
<Switch
254+
variant="small"
255+
checked={value ?? false}
256+
onCheckedChange={onChange}
257+
className={cn(dimmed && "opacity-50")}
258+
/>
259+
);
260+
}
261+
262+
function EnumControl({
263+
value,
264+
options,
265+
onChange,
266+
dimmed,
267+
}: {
268+
value: string | undefined;
269+
options: string[];
270+
onChange: (val: string) => void;
271+
dimmed: boolean;
272+
}) {
273+
const items = ["__unset__", ...options];
274+
275+
return (
276+
<Select
277+
variant="tertiary/small"
278+
value={value ?? "__unset__"}
279+
setValue={onChange}
280+
items={items}
281+
className={cn(dimmed && "opacity-50")}
282+
>
283+
{(items) =>
284+
items.map((item) => (
285+
<SelectItem key={item} value={item}>
286+
{item === "__unset__" ? "unset" : item}
287+
</SelectItem>
288+
))
289+
}
290+
</Select>
291+
);
292+
}
293+
294+
function StringControl({
295+
value,
296+
onChange,
297+
dimmed,
298+
}: {
299+
value: string;
300+
onChange: (val: string) => void;
301+
dimmed: boolean;
302+
}) {
303+
return (
304+
<Input
305+
variant="small"
306+
value={value}
307+
onChange={(e) => onChange(e.target.value)}
308+
placeholder="unset"
309+
className={cn("w-40", dimmed && "opacity-50")}
310+
/>
311+
);
312+
}

0 commit comments

Comments
 (0)