Skip to content

Commit ae08c9c

Browse files
authored
fix(webapp): admin feature flag number inputs and scrolling (#3979)
The global feature flags admin page had a few rough edges. The percentage flags are numeric (`z.coerce.number()`) but rendered as free-text inputs, so you could type non-numeric values that only failed validation after submitting - and the error surfaced behind the confirm dialog. The control-type detection now recognises numbers and renders a proper number input, with the min/max range as the placeholder so the type is clear even when the field is unset. The save error also shows inside the confirm dialog now, not just behind it. The action buttons were unreachable without zooming out. The admin layout wrapped each page in a plain block, so `h-full` page content overran the viewport by the height of the tab bar and got clipped by the `overflow-hidden` body. Making the layout a flex column bounds each page to the space below the tabs, so the existing per-page scroll works and the feature flags page scrolls like the Users/Orgs tabs. Also capped the confirm dialog's diff list so its footer stays on screen when there are many changes.
1 parent d34b699 commit ae08c9c

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)