Skip to content

Commit 0be8aa8

Browse files
committed
feat: add worker group dropdown and confirmation dialog with diff view
1 parent 8bb49ef commit 0be8aa8

1 file changed

Lines changed: 251 additions & 37 deletions

File tree

apps/webapp/app/routes/admin.feature-flags.tsx

Lines changed: 251 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,40 @@ import {
1414
import type { FlagControlType } from "~/v3/featureFlags.server";
1515
import { Button } from "~/components/primitives/Buttons";
1616
import { Callout } from "~/components/primitives/Callout";
17+
import {
18+
Dialog,
19+
DialogContent,
20+
DialogHeader,
21+
DialogDescription,
22+
DialogFooter,
23+
} from "~/components/primitives/Dialog";
1724
import { cn } from "~/utils/cn";
18-
import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "~/components/admin/FlagControls";
25+
import {
26+
UNSET_VALUE,
27+
BooleanControl,
28+
EnumControl,
29+
StringControl,
30+
} from "~/components/admin/FlagControls";
31+
import { Select, SelectItem } from "~/components/primitives/Select";
32+
33+
type WorkerGroup = { id: string; name: string };
1934

2035
export const loader = async ({ request }: LoaderFunctionArgs) => {
2136
const user = await requireUser(request);
2237
if (!user.admin) {
2338
return redirect("/");
2439
}
2540

26-
const globalFlags = await getGlobalFlags();
41+
const [globalFlags, workerGroups] = await Promise.all([
42+
getGlobalFlags(),
43+
prisma.workerInstanceGroup.findMany({
44+
select: { id: true, name: true },
45+
orderBy: { name: "asc" },
46+
}),
47+
]);
2748
const controlTypes = getAllFlagControlTypes();
2849

29-
return typedjson({ globalFlags, controlTypes });
50+
return typedjson({ globalFlags, controlTypes, workerGroups });
3051
};
3152

3253
export const action = async ({ request }: ActionFunctionArgs) => {
@@ -41,11 +62,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
4162
const controlTypes = getAllFlagControlTypes();
4263
const catalogKeys = Object.keys(controlTypes);
4364

44-
// For each catalog key: if value is present in newFlags, upsert it. If absent, delete the row.
4565
for (const key of catalogKeys) {
4666
if (key in newFlags) {
4767
const value = newFlags[key];
48-
// Validate the value against its schema
4968
const partial = { [key]: value };
5069
const result = validatePartialFeatureFlags(partial);
5170
if (result.success) {
@@ -56,7 +75,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
5675
});
5776
}
5877
} else {
59-
// Unset - delete the row if it exists
6078
await prisma.featureFlag.deleteMany({ where: { key } });
6179
}
6280
}
@@ -65,14 +83,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
6583
};
6684

6785
export default function AdminFeatureFlagsRoute() {
68-
const { globalFlags, controlTypes } = useTypedLoaderData<typeof loader>();
86+
const { globalFlags, controlTypes, workerGroups } = useTypedLoaderData<typeof loader>();
6987
const saveFetcher = useFetcher<{ success?: boolean; error?: string }>();
7088

7189
const [values, setValues] = useState<Record<string, unknown>>({});
7290
const [initialValues, setInitialValues] = useState<Record<string, unknown>>({});
7391
const [saveError, setSaveError] = useState<string | null>(null);
92+
const [confirmOpen, setConfirmOpen] = useState(false);
7493

75-
// Sync loader data into local state
7694
useEffect(() => {
7795
const loaded = (globalFlags ?? {}) as Record<string, unknown>;
7896
setValues({ ...loaded });
@@ -82,8 +100,8 @@ export default function AdminFeatureFlagsRoute() {
82100
useEffect(() => {
83101
if (saveFetcher.data?.success) {
84102
setSaveError(null);
85-
// Update initial to match saved state
86103
setInitialValues({ ...values });
104+
setConfirmOpen(false);
87105
} else if (saveFetcher.data?.error) {
88106
setSaveError(saveFetcher.data.error);
89107
}
@@ -111,6 +129,15 @@ export default function AdminFeatureFlagsRoute() {
111129
});
112130
};
113131

132+
const workerGroupMap = new Map(
133+
(workerGroups as WorkerGroup[]).map((wg) => [wg.id, wg.name])
134+
);
135+
136+
const resolveWorkerGroupDisplay = (id: string) => {
137+
const name = workerGroupMap.get(id);
138+
return name ? `${name} (${id.slice(0, 8)}...)` : id;
139+
};
140+
114141
const sortedFlagKeys = Object.keys(controlTypes as Record<string, FlagControlType>).sort();
115142

116143
return (
@@ -126,6 +153,8 @@ export default function AdminFeatureFlagsRoute() {
126153
const control = (controlTypes as Record<string, FlagControlType>)[key];
127154
const isSet = key in values;
128155

156+
const isWorkerGroup = key === "defaultWorkerInstanceGroupId";
157+
129158
return (
130159
<div
131160
key={key}
@@ -143,10 +172,14 @@ export default function AdminFeatureFlagsRoute() {
143172
isSet ? "text-text-bright" : "text-text-dimmed"
144173
)}
145174
>
146-
{key}
175+
{isWorkerGroup ? "defaultWorkerInstanceGroup" : key}
147176
</div>
148177
<div className="text-xs text-charcoal-400">
149-
{isSet ? `value: ${String(values[key])}` : "not set"}
178+
{isSet
179+
? isWorkerGroup
180+
? resolveWorkerGroupDisplay(values[key] as string)
181+
: `value: ${String(values[key])}`
182+
: "not set"}
150183
</div>
151184
</div>
152185

@@ -159,18 +192,10 @@ export default function AdminFeatureFlagsRoute() {
159192
unset
160193
</Button>
161194

162-
{control.type === "boolean" && (
163-
<BooleanControl
164-
value={isSet ? (values[key] as boolean) : undefined}
165-
onChange={(val) => setFlagValue(key, val)}
166-
dimmed={!isSet}
167-
/>
168-
)}
169-
170-
{control.type === "enum" && (
171-
<EnumControl
195+
{isWorkerGroup ? (
196+
<WorkerGroupControl
172197
value={isSet ? (values[key] as string) : undefined}
173-
options={control.options}
198+
workerGroups={workerGroups as WorkerGroup[]}
174199
onChange={(val) => {
175200
if (val === UNSET_VALUE) {
176201
unsetFlag(key);
@@ -180,20 +205,45 @@ export default function AdminFeatureFlagsRoute() {
180205
}}
181206
dimmed={!isSet}
182207
/>
183-
)}
208+
) : (
209+
<>
210+
{control.type === "boolean" && (
211+
<BooleanControl
212+
value={isSet ? (values[key] as boolean) : undefined}
213+
onChange={(val) => setFlagValue(key, val)}
214+
dimmed={!isSet}
215+
/>
216+
)}
184217

185-
{control.type === "string" && (
186-
<StringControl
187-
value={isSet ? (values[key] as string) : ""}
188-
onChange={(val) => {
189-
if (val === "") {
190-
unsetFlag(key);
191-
} else {
192-
setFlagValue(key, val);
193-
}
194-
}}
195-
dimmed={!isSet}
196-
/>
218+
{control.type === "enum" && (
219+
<EnumControl
220+
value={isSet ? (values[key] as string) : undefined}
221+
options={control.options}
222+
onChange={(val) => {
223+
if (val === UNSET_VALUE) {
224+
unsetFlag(key);
225+
} else {
226+
setFlagValue(key, val);
227+
}
228+
}}
229+
dimmed={!isSet}
230+
/>
231+
)}
232+
233+
{control.type === "string" && (
234+
<StringControl
235+
value={isSet ? (values[key] as string) : ""}
236+
onChange={(val) => {
237+
if (val === "") {
238+
unsetFlag(key);
239+
} else {
240+
setFlagValue(key, val);
241+
}
242+
}}
243+
dimmed={!isSet}
244+
/>
245+
)}
246+
</>
197247
)}
198248
</div>
199249
</div>
@@ -206,13 +256,177 @@ export default function AdminFeatureFlagsRoute() {
206256
<div className="flex justify-end gap-2">
207257
<Button
208258
variant="primary/small"
209-
onClick={handleSave}
259+
onClick={() => setConfirmOpen(true)}
210260
disabled={!isDirty || isSaving}
211261
>
212-
{isSaving ? "Saving..." : "Save Changes"}
262+
Review Changes
213263
</Button>
214264
</div>
215265
</div>
266+
267+
<ConfirmDialog
268+
open={confirmOpen}
269+
onOpenChange={setConfirmOpen}
270+
initialValues={initialValues}
271+
newValues={values}
272+
controlTypes={controlTypes as Record<string, FlagControlType>}
273+
workerGroupMap={workerGroupMap}
274+
onConfirm={handleSave}
275+
isSaving={isSaving}
276+
/>
216277
</main>
217278
);
218279
}
280+
281+
// --- Worker Group Select ---
282+
283+
function WorkerGroupControl({
284+
value,
285+
workerGroups,
286+
onChange,
287+
dimmed,
288+
}: {
289+
value: string | undefined;
290+
workerGroups: WorkerGroup[];
291+
onChange: (val: string) => void;
292+
dimmed: boolean;
293+
}) {
294+
const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)];
295+
296+
return (
297+
<Select
298+
variant="tertiary/small"
299+
value={value ?? UNSET_VALUE}
300+
setValue={onChange}
301+
items={items}
302+
text={(val) => {
303+
if (val === UNSET_VALUE) return "unset";
304+
const wg = workerGroups.find((w) => w.id === val);
305+
return wg ? wg.name : val;
306+
}}
307+
className={cn(dimmed && "opacity-50")}
308+
>
309+
{(items) =>
310+
items.map((item) => {
311+
const wg = workerGroups.find((w) => w.id === item);
312+
return (
313+
<SelectItem key={item} value={item}>
314+
{item === UNSET_VALUE ? "unset" : wg ? wg.name : item}
315+
</SelectItem>
316+
);
317+
})
318+
}
319+
</Select>
320+
);
321+
}
322+
323+
// --- Confirmation Dialog with Diff ---
324+
325+
function ConfirmDialog({
326+
open,
327+
onOpenChange,
328+
initialValues,
329+
newValues,
330+
controlTypes,
331+
workerGroupMap,
332+
onConfirm,
333+
isSaving,
334+
}: {
335+
open: boolean;
336+
onOpenChange: (open: boolean) => void;
337+
initialValues: Record<string, unknown>;
338+
newValues: Record<string, unknown>;
339+
controlTypes: Record<string, FlagControlType>;
340+
workerGroupMap: Map<string, string>;
341+
onConfirm: () => void;
342+
isSaving: boolean;
343+
}) {
344+
const allKeys = Object.keys(controlTypes).sort();
345+
346+
const changes = allKeys.flatMap((key) => {
347+
const wasSet = key in initialValues;
348+
const isSet = key in newValues;
349+
const oldVal = initialValues[key];
350+
const newVal = newValues[key];
351+
352+
if (!wasSet && !isSet) return [];
353+
if (wasSet && isSet && stableStringify(oldVal) === stableStringify(newVal)) return [];
354+
355+
const displayKey =
356+
key === "defaultWorkerInstanceGroupId" ? "defaultWorkerInstanceGroup" : key;
357+
358+
const formatVal = (val: unknown) => {
359+
if (key === "defaultWorkerInstanceGroupId" && typeof val === "string") {
360+
const name = workerGroupMap.get(val);
361+
return name ? `${name} (${val.slice(0, 8)}...)` : String(val);
362+
}
363+
return String(val);
364+
};
365+
366+
if (!wasSet && isSet) {
367+
return [{ key: displayKey, type: "added" as const, newVal: formatVal(newVal) }];
368+
}
369+
if (wasSet && !isSet) {
370+
return [{ key: displayKey, type: "removed" as const, oldVal: formatVal(oldVal) }];
371+
}
372+
return [
373+
{
374+
key: displayKey,
375+
type: "changed" as const,
376+
oldVal: formatVal(oldVal),
377+
newVal: formatVal(newVal),
378+
},
379+
];
380+
});
381+
382+
return (
383+
<Dialog open={open} onOpenChange={onOpenChange}>
384+
<DialogContent className="sm:max-w-lg">
385+
<DialogHeader>Confirm Feature Flag Changes</DialogHeader>
386+
<DialogDescription>
387+
These changes affect all organizations globally. Please review carefully.
388+
</DialogDescription>
389+
390+
<div className="flex flex-col gap-2 py-2">
391+
{changes.length === 0 ? (
392+
<p className="text-sm text-text-dimmed">No changes to apply.</p>
393+
) : (
394+
changes.map((change) => (
395+
<div
396+
key={change.key}
397+
className="rounded-md border border-charcoal-600 bg-charcoal-800 px-3 py-2 font-mono text-xs"
398+
>
399+
<div className="font-sans text-sm text-text-bright">{change.key}</div>
400+
{change.type === "added" && (
401+
<div className="mt-1 text-green-400">+ {change.newVal}</div>
402+
)}
403+
{change.type === "removed" && (
404+
<div className="mt-1 text-red-400">- {change.oldVal} (unset)</div>
405+
)}
406+
{change.type === "changed" && (
407+
<>
408+
<div className="mt-1 text-red-400">- {change.oldVal}</div>
409+
<div className="text-green-400">+ {change.newVal}</div>
410+
</>
411+
)}
412+
</div>
413+
))
414+
)}
415+
</div>
416+
417+
<DialogFooter>
418+
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
419+
Cancel
420+
</Button>
421+
<Button
422+
variant="danger/small"
423+
onClick={onConfirm}
424+
disabled={isSaving || changes.length === 0}
425+
>
426+
{isSaving ? "Saving..." : "Apply Changes"}
427+
</Button>
428+
</DialogFooter>
429+
</DialogContent>
430+
</Dialog>
431+
);
432+
}

0 commit comments

Comments
 (0)