Skip to content

Commit 82834f8

Browse files
committed
fix(webapp): admin feature flag number inputs and scrolling
1 parent d34b699 commit 82834f8

5 files changed

Lines changed: 89 additions & 3 deletions

File tree

apps/webapp/app/components/admin/FeatureFlagsDialog.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
UNSET_VALUE,
1919
BooleanControl,
2020
EnumControl,
21+
NumberControl,
2122
StringControl,
2223
WorkerGroupControl,
2324
type WorkerGroup,
@@ -242,6 +243,20 @@ export function FeatureFlagsDialog({
242243
}}
243244
dimmed={!isOverridden}
244245
/>
246+
) : control.type === "number" ? (
247+
<NumberControl
248+
value={isOverridden ? (overrides[key] as number) : undefined}
249+
min={control.min}
250+
max={control.max}
251+
onChange={(val) => {
252+
if (val === undefined) {
253+
unsetFlag(key);
254+
} else {
255+
setFlagValue(key, val);
256+
}
257+
}}
258+
dimmed={!isOverridden}
259+
/>
245260
) : control.type === "string" ? (
246261
<StringControl
247262
value={isOverridden ? (overrides[key] as string) : ""}

apps/webapp/app/components/admin/FlagControls.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,41 @@ export function WorkerGroupControl({
9999
);
100100
}
101101

102+
export function NumberControl({
103+
value,
104+
onChange,
105+
min,
106+
max,
107+
dimmed,
108+
}: {
109+
value: number | undefined;
110+
onChange: (val: number | undefined) => void;
111+
min?: number;
112+
max?: number;
113+
dimmed: boolean;
114+
}) {
115+
// Number and string fields share the same input shape; surface the range
116+
// (or "number") as the placeholder so an unset field still signals its type.
117+
const placeholder = min !== undefined && max !== undefined ? `${min}-${max}` : "number";
118+
return (
119+
<Input
120+
type="number"
121+
variant="small"
122+
// Empty string when unset so the placeholder shows instead of "0".
123+
value={value ?? ""}
124+
min={min}
125+
max={max}
126+
step={1}
127+
onChange={(e) => {
128+
const next = e.target.valueAsNumber;
129+
onChange(Number.isNaN(next) ? undefined : next);
130+
}}
131+
placeholder={placeholder}
132+
className={cn("w-40", dimmed && "opacity-50")}
133+
/>
134+
);
135+
}
136+
102137
export function StringControl({
103138
value,
104139
onChange,

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
UNSET_VALUE,
3636
BooleanControl,
3737
EnumControl,
38+
NumberControl,
3839
StringControl,
3940
WorkerGroupControl,
4041
type WorkerGroup,
@@ -352,6 +353,22 @@ export default function AdminFeatureFlagsRoute() {
352353
/>
353354
)}
354355

356+
{control.type === "number" && (
357+
<NumberControl
358+
value={isSet ? (values[key] as number) : undefined}
359+
min={control.min}
360+
max={control.max}
361+
onChange={(val) => {
362+
if (val === undefined) {
363+
unsetFlag(key);
364+
} else {
365+
setFlagValue(key, val);
366+
}
367+
}}
368+
dimmed={!isSet}
369+
/>
370+
)}
371+
355372
{control.type === "string" && (
356373
<StringControl
357374
value={isSet ? (values[key] as string) : ""}
@@ -400,6 +417,7 @@ export default function AdminFeatureFlagsRoute() {
400417
lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS}
401418
onConfirm={handleSave}
402419
isSaving={isSaving}
420+
saveError={saveError}
403421
/>
404422
</main>
405423
);
@@ -467,6 +485,7 @@ function ConfirmDialog({
467485
lockedKeys,
468486
onConfirm,
469487
isSaving,
488+
saveError,
470489
}: {
471490
open: boolean;
472491
onOpenChange: (open: boolean) => void;
@@ -476,6 +495,7 @@ function ConfirmDialog({
476495
lockedKeys: readonly string[];
477496
onConfirm: () => void;
478497
isSaving: boolean;
498+
saveError: string | null;
479499
}) {
480500
const editableKeys = Object.keys(controlTypes)
481501
.filter((key) => !lockedKeys.includes(key))
@@ -519,7 +539,7 @@ function ConfirmDialog({
519539
These changes affect all organizations globally. Please review carefully.
520540
</DialogDescription>
521541

522-
<div className="flex flex-col gap-2 pb-2">
542+
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto pb-2">
523543
{changes.length === 0 ? (
524544
<p className="text-sm text-text-dimmed">No changes to apply.</p>
525545
) : (
@@ -546,6 +566,8 @@ function ConfirmDialog({
546566
)}
547567
</div>
548568

569+
{saveError && <Callout variant="error">{saveError}</Callout>}
570+
549571
<DialogFooter>
550572
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
551573
Cancel

apps/webapp/app/routes/admin.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function Page() {
1515
const searchSuffix = search ? `?search=${encodeURIComponent(search)}` : "";
1616

1717
return (
18-
<div className="h-full w-full">
18+
<div className="flex h-full w-full flex-col">
1919
<div className="flex items-center justify-between p-4">
2020
<Tabs
2121
tabs={[
@@ -59,7 +59,11 @@ export default function Page() {
5959
Back to me
6060
</LinkButton>
6161
</div>
62-
<Outlet />
62+
{/* min-h-0 lets the page's own scroll container bound itself to the
63+
space below the tabs instead of overflowing past the viewport. */}
64+
<div className="min-h-0 flex-1">
65+
<Outlet />
66+
</div>
6367
</div>
6468
);
6569
}

apps/webapp/app/v3/featureFlags.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export function validatePartialFeatureFlags(values: Record<string, unknown>) {
8585
export type FlagControlType =
8686
| { type: "boolean" }
8787
| { type: "enum"; options: string[] }
88+
| { type: "number"; min?: number; max?: number }
8889
| { type: "string" };
8990

9091
export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType {
@@ -98,6 +99,15 @@ export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType {
9899
return { type: "enum", options: schema._def.values as string[] };
99100
}
100101

102+
// z.coerce.number() reports as ZodNumber; pull min/max out of its checks
103+
// so the UI can render a constrained number input instead of free text.
104+
if (typeName === "ZodNumber") {
105+
const checks = (schema._def.checks ?? []) as Array<{ kind: string; value?: number }>;
106+
const min = checks.find((c) => c.kind === "min")?.value;
107+
const max = checks.find((c) => c.kind === "max")?.value;
108+
return { type: "number", min, max };
109+
}
110+
101111
return { type: "string" };
102112
}
103113

0 commit comments

Comments
 (0)