Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/webapp/app/components/admin/FeatureFlagsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
UNSET_VALUE,
BooleanControl,
EnumControl,
NumberControl,
StringControl,
WorkerGroupControl,
type WorkerGroup,
Expand Down Expand Up @@ -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) : ""}
Expand Down
35 changes: 35 additions & 0 deletions apps/webapp/app/components/admin/FlagControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion apps/webapp/app/routes/admin.feature-flags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
UNSET_VALUE,
BooleanControl,
EnumControl,
NumberControl,
StringControl,
WorkerGroupControl,
type WorkerGroup,
Expand Down Expand Up @@ -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) : ""}
Expand Down Expand Up @@ -400,6 +417,7 @@ export default function AdminFeatureFlagsRoute() {
lockedKeys={unlocked ? [] : GLOBAL_LOCKED_FLAGS}
onConfirm={handleSave}
isSaving={isSaving}
saveError={saveError}
/>
</main>
);
Expand Down Expand Up @@ -467,6 +485,7 @@ function ConfirmDialog({
lockedKeys,
onConfirm,
isSaving,
saveError,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
Expand All @@ -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))
Expand Down Expand Up @@ -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>
) : (
Expand All @@ -546,6 +566,8 @@ function ConfirmDialog({
)}
</div>

{saveError && <Callout variant="error">{saveError}</Callout>}

<DialogFooter>
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
Cancel
Expand Down
8 changes: 6 additions & 2 deletions apps/webapp/app/routes/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={[
Expand Down Expand Up @@ -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 changes: 10 additions & 0 deletions apps/webapp/app/v3/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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" };
}

Expand Down