|
| 1 | +import { useFetcher } from "@remix-run/react"; |
| 2 | +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; |
| 3 | +import { useEffect, useState } from "react"; |
| 4 | +import { json } from "@remix-run/server-runtime"; |
| 5 | +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; |
| 6 | +import { prisma } from "~/db.server"; |
| 7 | +import { requireUser } from "~/services/session.server"; |
| 8 | +import { |
| 9 | + flags as getGlobalFlags, |
| 10 | + getAllFlagControlTypes, |
| 11 | + validatePartialFeatureFlags, |
| 12 | +} from "~/v3/featureFlags.server"; |
| 13 | +import type { FlagControlType } from "~/v3/featureFlags.server"; |
| 14 | +import { Button } from "~/components/primitives/Buttons"; |
| 15 | +import { Callout } from "~/components/primitives/Callout"; |
| 16 | +import { cn } from "~/utils/cn"; |
| 17 | +import { UNSET_VALUE, BooleanControl, EnumControl, StringControl } from "~/components/admin/FlagControls"; |
| 18 | + |
| 19 | +export const loader = async ({ request }: LoaderFunctionArgs) => { |
| 20 | + const user = await requireUser(request); |
| 21 | + if (!user.admin) { |
| 22 | + return redirect("/"); |
| 23 | + } |
| 24 | + |
| 25 | + const globalFlags = await getGlobalFlags(); |
| 26 | + const controlTypes = getAllFlagControlTypes(); |
| 27 | + |
| 28 | + return typedjson({ globalFlags, controlTypes }); |
| 29 | +}; |
| 30 | + |
| 31 | +export const action = async ({ request }: ActionFunctionArgs) => { |
| 32 | + const user = await requireUser(request); |
| 33 | + if (!user.admin) { |
| 34 | + throw new Response("Unauthorized", { status: 403 }); |
| 35 | + } |
| 36 | + |
| 37 | + const body = await request.json(); |
| 38 | + const { flags: newFlags } = body as { flags: Record<string, unknown> }; |
| 39 | + |
| 40 | + const controlTypes = getAllFlagControlTypes(); |
| 41 | + const catalogKeys = Object.keys(controlTypes); |
| 42 | + |
| 43 | + // For each catalog key: if value is present in newFlags, upsert it. If absent, delete the row. |
| 44 | + for (const key of catalogKeys) { |
| 45 | + if (key in newFlags) { |
| 46 | + const value = newFlags[key]; |
| 47 | + // Validate the value against its schema |
| 48 | + const partial = { [key]: value }; |
| 49 | + const result = validatePartialFeatureFlags(partial); |
| 50 | + if (result.success) { |
| 51 | + await prisma.featureFlag.upsert({ |
| 52 | + where: { key }, |
| 53 | + create: { key, value: value as any }, |
| 54 | + update: { value: value as any }, |
| 55 | + }); |
| 56 | + } |
| 57 | + } else { |
| 58 | + // Unset - delete the row if it exists |
| 59 | + await prisma.featureFlag.deleteMany({ where: { key } }); |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + return json({ success: true }); |
| 64 | +}; |
| 65 | + |
| 66 | +export default function AdminFeatureFlagsRoute() { |
| 67 | + const { globalFlags, controlTypes } = useTypedLoaderData<typeof loader>(); |
| 68 | + const saveFetcher = useFetcher<{ success?: boolean; error?: string }>(); |
| 69 | + |
| 70 | + const [values, setValues] = useState<Record<string, unknown>>({}); |
| 71 | + const [initialValues, setInitialValues] = useState<Record<string, unknown>>({}); |
| 72 | + const [saveError, setSaveError] = useState<string | null>(null); |
| 73 | + |
| 74 | + // Sync loader data into local state |
| 75 | + useEffect(() => { |
| 76 | + const loaded = (globalFlags ?? {}) as Record<string, unknown>; |
| 77 | + setValues({ ...loaded }); |
| 78 | + setInitialValues({ ...loaded }); |
| 79 | + }, [globalFlags]); |
| 80 | + |
| 81 | + useEffect(() => { |
| 82 | + if (saveFetcher.data?.success) { |
| 83 | + setSaveError(null); |
| 84 | + // Update initial to match saved state |
| 85 | + setInitialValues({ ...values }); |
| 86 | + } else if (saveFetcher.data?.error) { |
| 87 | + setSaveError(saveFetcher.data.error); |
| 88 | + } |
| 89 | + }, [saveFetcher.data]); |
| 90 | + |
| 91 | + const isDirty = JSON.stringify(values) !== JSON.stringify(initialValues); |
| 92 | + const isSaving = saveFetcher.state === "submitting"; |
| 93 | + |
| 94 | + const setFlagValue = (key: string, value: unknown) => { |
| 95 | + setValues((prev) => ({ ...prev, [key]: value })); |
| 96 | + }; |
| 97 | + |
| 98 | + const unsetFlag = (key: string) => { |
| 99 | + setValues((prev) => { |
| 100 | + const next = { ...prev }; |
| 101 | + delete next[key]; |
| 102 | + return next; |
| 103 | + }); |
| 104 | + }; |
| 105 | + |
| 106 | + const handleSave = () => { |
| 107 | + saveFetcher.submit(JSON.stringify({ flags: values }), { |
| 108 | + method: "POST", |
| 109 | + encType: "application/json", |
| 110 | + }); |
| 111 | + }; |
| 112 | + |
| 113 | + const sortedFlagKeys = Object.keys(controlTypes as Record<string, FlagControlType>).sort(); |
| 114 | + |
| 115 | + return ( |
| 116 | + <main className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last"> |
| 117 | + <div className="max-w-2xl space-y-4"> |
| 118 | + <p className="text-sm text-text-dimmed"> |
| 119 | + Global defaults for all organizations. Org-level overrides take precedence. |
| 120 | + </p> |
| 121 | + |
| 122 | + <div className="flex flex-col gap-1.5"> |
| 123 | + {sortedFlagKeys.map((key) => { |
| 124 | + const control = (controlTypes as Record<string, FlagControlType>)[key]; |
| 125 | + const isSet = key in values; |
| 126 | + |
| 127 | + return ( |
| 128 | + <div |
| 129 | + key={key} |
| 130 | + className={cn( |
| 131 | + "flex items-center justify-between rounded-md border px-3 py-2.5", |
| 132 | + isSet |
| 133 | + ? "border-indigo-500/20 bg-indigo-500/5" |
| 134 | + : "border-transparent bg-charcoal-750" |
| 135 | + )} |
| 136 | + > |
| 137 | + <div className="min-w-0 flex-1"> |
| 138 | + <div |
| 139 | + className={cn( |
| 140 | + "truncate text-sm", |
| 141 | + isSet ? "text-text-bright" : "text-text-dimmed" |
| 142 | + )} |
| 143 | + > |
| 144 | + {key} |
| 145 | + </div> |
| 146 | + <div className="text-xs text-charcoal-400"> |
| 147 | + {isSet ? `value: ${String(values[key])}` : "not set"} |
| 148 | + </div> |
| 149 | + </div> |
| 150 | + |
| 151 | + <div className="flex items-center gap-2"> |
| 152 | + <Button |
| 153 | + variant="minimal/small" |
| 154 | + onClick={() => unsetFlag(key)} |
| 155 | + className={cn(!isSet && "invisible")} |
| 156 | + > |
| 157 | + unset |
| 158 | + </Button> |
| 159 | + |
| 160 | + {control.type === "boolean" && ( |
| 161 | + <BooleanControl |
| 162 | + value={isSet ? (values[key] as boolean) : undefined} |
| 163 | + onChange={(val) => setFlagValue(key, val)} |
| 164 | + dimmed={!isSet} |
| 165 | + /> |
| 166 | + )} |
| 167 | + |
| 168 | + {control.type === "enum" && ( |
| 169 | + <EnumControl |
| 170 | + value={isSet ? (values[key] as string) : undefined} |
| 171 | + options={control.options} |
| 172 | + onChange={(val) => { |
| 173 | + if (val === UNSET_VALUE) { |
| 174 | + unsetFlag(key); |
| 175 | + } else { |
| 176 | + setFlagValue(key, val); |
| 177 | + } |
| 178 | + }} |
| 179 | + dimmed={!isSet} |
| 180 | + /> |
| 181 | + )} |
| 182 | + |
| 183 | + {control.type === "string" && ( |
| 184 | + <StringControl |
| 185 | + value={isSet ? (values[key] as string) : ""} |
| 186 | + onChange={(val) => { |
| 187 | + if (val === "") { |
| 188 | + unsetFlag(key); |
| 189 | + } else { |
| 190 | + setFlagValue(key, val); |
| 191 | + } |
| 192 | + }} |
| 193 | + dimmed={!isSet} |
| 194 | + /> |
| 195 | + )} |
| 196 | + </div> |
| 197 | + </div> |
| 198 | + ); |
| 199 | + })} |
| 200 | + </div> |
| 201 | + |
| 202 | + {saveError && <Callout variant="error">{saveError}</Callout>} |
| 203 | + |
| 204 | + <div className="flex justify-end gap-2"> |
| 205 | + <Button |
| 206 | + variant="primary/small" |
| 207 | + onClick={handleSave} |
| 208 | + disabled={!isDirty || isSaving} |
| 209 | + > |
| 210 | + {isSaving ? "Saving..." : "Save Changes"} |
| 211 | + </Button> |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + </main> |
| 215 | + ); |
| 216 | +} |
0 commit comments