@@ -14,19 +14,40 @@ import {
1414import type { FlagControlType } from "~/v3/featureFlags.server" ;
1515import { Button } from "~/components/primitives/Buttons" ;
1616import { Callout } from "~/components/primitives/Callout" ;
17+ import {
18+ Dialog ,
19+ DialogContent ,
20+ DialogHeader ,
21+ DialogDescription ,
22+ DialogFooter ,
23+ } from "~/components/primitives/Dialog" ;
1724import { 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
2035export 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
3253export 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
6785export 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