Files
triggerdotdev--trigger.dev/apps/webapp/app/components/admin/FlagControls.tsx
nicktrn ae08c9cb60 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.
2026-06-17 19:02:01 +00:00

156 lines
3.5 KiB
TypeScript

import { Switch } from "~/components/primitives/Switch";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Input } from "~/components/primitives/Input";
import { cn } from "~/utils/cn";
export const UNSET_VALUE = "__unset__";
export function BooleanControl({
value,
onChange,
dimmed,
}: {
value: boolean | undefined;
onChange: (val: boolean) => void;
dimmed: boolean;
}) {
return (
<Switch
variant="small"
checked={value ?? false}
onCheckedChange={onChange}
className={cn(dimmed && "opacity-50")}
/>
);
}
export function EnumControl({
value,
options,
onChange,
dimmed,
}: {
value: string | undefined;
options: string[];
onChange: (val: string) => void;
dimmed: boolean;
}) {
const items = [UNSET_VALUE, ...options];
return (
<Select
variant="tertiary/small"
value={value ?? UNSET_VALUE}
setValue={onChange}
items={items}
text={(val) => (val === UNSET_VALUE ? "unset" : val)}
className={cn(dimmed && "opacity-50")}
>
{(items) =>
items.map((item) => (
<SelectItem key={item} value={item}>
{item === UNSET_VALUE ? "unset" : item}
</SelectItem>
))
}
</Select>
);
}
export type WorkerGroup = { id: string; name: string };
export function WorkerGroupControl({
value,
workerGroups,
onChange,
dimmed,
}: {
value: string | undefined;
workerGroups: WorkerGroup[];
onChange: (val: string) => void;
dimmed: boolean;
}) {
const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)];
return (
<Select
variant="tertiary/small"
value={value ?? UNSET_VALUE}
setValue={onChange}
items={items}
text={(val) => {
if (val === UNSET_VALUE) return "unset";
const wg = workerGroups.find((w) => w.id === val);
return wg ? wg.name : val;
}}
className={cn(dimmed && "opacity-50")}
>
{(items) =>
items.map((item) => {
const wg = workerGroups.find((w) => w.id === item);
return (
<SelectItem key={item} value={item}>
{item === UNSET_VALUE ? "unset" : wg ? wg.name : item}
</SelectItem>
);
})
}
</Select>
);
}
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,
dimmed,
}: {
value: string;
onChange: (val: string) => void;
dimmed: boolean;
}) {
return (
<Input
variant="small"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="unset"
className={cn("w-40", dimmed && "opacity-50")}
/>
);
}