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.
This commit is contained in:
nicktrn
2026-06-17 20:02:01 +01:00
committed by GitHub
parent d34b699950
commit ae08c9cb60
5 changed files with 89 additions and 3 deletions
@@ -18,6 +18,7 @@ import {
UNSET_VALUE,
BooleanControl,
EnumControl,
NumberControl,
StringControl,
WorkerGroupControl,
type WorkerGroup,
@@ -242,6 +243,20 @@ export function FeatureFlagsDialog({
}}
dimmed={!isOverridden}
/>
) : control.type === "number" ? (
<NumberControl
value={isOverridden ? (overrides[key] as number) : undefined}
min={control.min}
max={control.max}
onChange={(val) => {
if (val === undefined) {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isOverridden}
/>
) : control.type === "string" ? (
<StringControl
value={isOverridden ? (overrides[key] as string) : ""}
@@ -99,6 +99,41 @@ export function WorkerGroupControl({
);
}
export function NumberControl({
value,
onChange,
min,
max,
dimmed,
}: {
value: number | undefined;
onChange: (val: number | undefined) => void;
min?: number;
max?: number;
dimmed: boolean;
}) {
// Number and string fields share the same input shape; surface the range
// (or "number") as the placeholder so an unset field still signals its type.
const placeholder = min !== undefined && max !== undefined ? `${min}-${max}` : "number";
return (
<Input
type="number"
variant="small"
// Empty string when unset so the placeholder shows instead of "0".
value={value ?? ""}
min={min}
max={max}
step={1}
onChange={(e) => {
const next = e.target.valueAsNumber;
onChange(Number.isNaN(next) ? undefined : next);
}}
placeholder={placeholder}
className={cn("w-40", dimmed && "opacity-50")}
/>
);
}
export function StringControl({
value,
onChange,
+23 -1
View File
@@ -35,6 +35,7 @@ import {
UNSET_VALUE,
BooleanControl,
EnumControl,
NumberControl,
StringControl,
WorkerGroupControl,
type WorkerGroup,
@@ -352,6 +353,22 @@ export default function AdminFeatureFlagsRoute() {
/>
)}
{control.type === "number" && (
<NumberControl
value={isSet ? (values[key] as number) : undefined}
min={control.min}
max={control.max}
onChange={(val) => {
if (val === undefined) {
unsetFlag(key);
} else {
setFlagValue(key, val);
}
}}
dimmed={!isSet}
/>
)}
{control.type === "string" && (
<StringControl
value={isSet ? (values[key] as string) : ""}
@@ -400,6 +417,7 @@ export default function AdminFeatureFlagsRoute() {
lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS}
onConfirm={handleSave}
isSaving={isSaving}
saveError={saveError}
/>
</main>
);
@@ -467,6 +485,7 @@ function ConfirmDialog({
lockedKeys,
onConfirm,
isSaving,
saveError,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -476,6 +495,7 @@ function ConfirmDialog({
lockedKeys: readonly string[];
onConfirm: () => void;
isSaving: boolean;
saveError: string | null;
}) {
const editableKeys = Object.keys(controlTypes)
.filter((key) => !lockedKeys.includes(key))
@@ -519,7 +539,7 @@ function ConfirmDialog({
These changes affect all organizations globally. Please review carefully.
</DialogDescription>
<div className="flex flex-col gap-2 pb-2">
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto pb-2">
{changes.length === 0 ? (
<p className="text-sm text-text-dimmed">No changes to apply.</p>
) : (
@@ -546,6 +566,8 @@ function ConfirmDialog({
)}
</div>
{saveError && <Callout variant="error">{saveError}</Callout>}
<DialogFooter>
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
Cancel
+6 -2
View File
@@ -15,7 +15,7 @@ export default function Page() {
const searchSuffix = search ? `?search=${encodeURIComponent(search)}` : "";
return (
<div className="h-full w-full">
<div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between p-4">
<Tabs
tabs={[
@@ -59,7 +59,11 @@ export default function Page() {
Back to me
</LinkButton>
</div>
<Outlet />
{/* min-h-0 lets the page's own scroll container bound itself to the
space below the tabs instead of overflowing past the viewport. */}
<div className="min-h-0 flex-1">
<Outlet />
</div>
</div>
);
}
+10
View File
@@ -85,6 +85,7 @@ export function validatePartialFeatureFlags(values: Record<string, unknown>) {
export type FlagControlType =
| { type: "boolean" }
| { type: "enum"; options: string[] }
| { type: "number"; min?: number; max?: number }
| { type: "string" };
export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType {
@@ -98,6 +99,15 @@ export function getFlagControlType(schema: z.ZodTypeAny): FlagControlType {
return { type: "enum", options: schema._def.values as string[] };
}
// z.coerce.number() reports as ZodNumber; pull min/max out of its checks
// so the UI can render a constrained number input instead of free text.
if (typeName === "ZodNumber") {
const checks = (schema._def.checks ?? []) as Array<{ kind: string; value?: number }>;
const min = checks.find((c) => c.kind === "min")?.value;
const max = checks.find((c) => c.kind === "max")?.value;
return { type: "number", min, max };
}
return { type: "string" };
}