feat: add themed select menu

This commit is contained in:
Matthew Meszaros
2026-06-07 12:39:33 +02:00
parent b1518579bb
commit f9d667ed6d
8 changed files with 274 additions and 126 deletions
+43 -27
View File
@@ -2,6 +2,12 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { searchAdminAuditLogs } from "@/lib/api/client/app/admin/audit";
import type { AdminAuditLog, AdminAuditLogSearch } from "@/lib/api/models/app/admin/Audit";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
const LIMIT_OPTIONS: SelectOption[] = [25, 50, 100].map((n) => ({
value: String(n),
label: String(n),
}));
// Free-form action / target_type strings used by handlers across the app.
// These are populated dynamically from the result set too — anything new the
@@ -87,6 +93,22 @@ export default function AdminAuditPage() {
return Array.from(set).sort();
}, [data]);
const actionOptions = useMemo<SelectOption[]>(
() => [
{ value: "", label: "(any)" },
...allActions.map((a) => ({ value: a, label: a })),
],
[allActions],
);
const entityTypeOptions = useMemo<SelectOption[]>(
() => [
{ value: "", label: "(any)" },
...allEntityTypes.map((t) => ({ value: t, label: t })),
],
[allEntityTypes],
);
return (
<div>
<div className="flex items-center justify-between mb-4">
@@ -120,28 +142,22 @@ export default function AdminAuditPage() {
<div className="border rounded-lg p-3 mb-3 bg-slate-50">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Field label="Action">
<select
<SelectMenu
value={filters.action ?? ""}
onChange={(e) => applyFilter({ action: e.target.value || undefined })}
className={inp}
>
<option value="">(any)</option>
{allActions.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
onChange={(v) => applyFilter({ action: v || undefined })}
options={actionOptions}
className="w-full"
aria-label="Action"
/>
</Field>
<Field label="Target type">
<select
<SelectMenu
value={filters.target_type ?? ""}
onChange={(e) => applyFilter({ target_type: e.target.value || undefined })}
className={inp}
>
<option value="">(any)</option>
{allEntityTypes.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
onChange={(v) => applyFilter({ target_type: v || undefined })}
options={entityTypeOptions}
className="w-full"
aria-label="Target type"
/>
</Field>
<Field label="Target ID">
<input
@@ -177,15 +193,15 @@ export default function AdminAuditPage() {
</Field>
</div>
<div className="flex items-center gap-3 mt-3">
<div className="text-xs text-slate-500">
Limit:{" "}
<select
value={filters.limit ?? 50}
onChange={(e) => applyFilter({ limit: parseInt(e.target.value, 10) })}
className="border rounded px-2 py-1 text-xs"
>
{[25, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
<div className="flex items-center gap-2 text-xs text-slate-500">
Limit:
<SelectMenu
value={String(filters.limit ?? 50)}
onChange={(v) => applyFilter({ limit: parseInt(v, 10) })}
options={LIMIT_OPTIONS}
minWidth={80}
aria-label="Result limit"
/>
</div>
<button
onClick={resetFilters}
+26 -14
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import {
applyProfileToAll,
checkReleases,
@@ -23,6 +24,11 @@ import type {
type Tab = "aws" | "profiles";
const APP_ENV_OPTIONS: SelectOption[] = [
{ value: "prod", label: "prod" },
{ value: "dev", label: "dev" },
];
export default function AdminCredentialsPage() {
const [tab, setTab] = useState<Tab>("aws");
@@ -414,6 +420,14 @@ function ProfileForm({
const [channel, setChannel] = useState<"pinned" | "stable" | "dev">(initial?.release_channel ?? "pinned");
const [autoUpdate, setAutoUpdate] = useState<boolean>(initial?.auto_update ?? false);
const awsCredentialOptions = useMemo<SelectOption[]>(
() => [
{ value: "", label: "(none)" },
...awsOptions.map((a) => ({ value: a.id, label: `${a.name} (${a.region})` })),
],
[awsOptions],
);
const mut = useMutation({
mutationFn: async () => {
const res = initial
@@ -438,10 +452,12 @@ function ProfileForm({
<div className="grid grid-cols-2 gap-3">
<Field label="App env">
<select value={form.app_env} onChange={(e) => setForm({ ...form, app_env: e.target.value })} className={inp}>
<option value="prod">prod</option>
<option value="dev">dev</option>
</select>
<SelectMenu
value={form.app_env ?? ""}
onChange={(v) => setForm({ ...form, app_env: v })}
options={APP_ENV_OPTIONS}
className="w-full"
/>
</Field>
<Field label="Worker image">
<input value={form.worker_image} onChange={(e) => setForm({ ...form, worker_image: e.target.value })} className={inp} />
@@ -449,16 +465,12 @@ function ProfileForm({
</div>
<Field label="AWS credentials">
<select
<SelectMenu
value={form.aws_credential_id ?? ""}
onChange={(e) => setForm({ ...form, aws_credential_id: e.target.value || null })}
className={inp}
>
<option value="">(none)</option>
{awsOptions.map((a) => (
<option key={a.id} value={a.id}>{a.name} ({a.region})</option>
))}
</select>
onChange={(v) => setForm({ ...form, aws_credential_id: v || null })}
options={awsCredentialOptions}
className="w-full"
/>
</Field>
<h4 className="font-semibold text-slate-600 text-sm mt-4 mb-1">Kafka</h4>
+47 -42
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import {
convertWorkerToDedicated,
deleteWorker,
@@ -81,6 +82,16 @@ export default function AdminWorkerDetailPage() {
});
const profiles = useQuery({ queryKey: ["admin", "profiles"], queryFn: listWorkerProfiles });
const profileOptions: SelectOption[] = useMemo(
() => [
{ value: "", label: "(use backend env defaults)" },
...(profiles.data?.data?.map((p) => ({
value: p.id,
label: `${p.name} · ${p.app_env}`,
})) ?? []),
],
[profiles.data],
);
const assignProfile = useMutation({
mutationFn: (profileID: string | null) => assignWorkerProfile(id, profileID),
...opts("assign profile"),
@@ -261,19 +272,12 @@ export default function AdminWorkerDetailPage() {
<Section title="Worker profile">
<div className="flex items-center gap-3">
<select
<SelectMenu
value={w.profile_id ?? ""}
onChange={(e) => assignProfile.mutate(e.target.value || null)}
onChange={(v) => assignProfile.mutate(v || null)}
options={profileOptions}
disabled={assignProfile.isPending}
className="border rounded px-3 py-1.5 text-sm"
>
<option value="">(use backend env defaults)</option>
{profiles.data?.data?.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {p.app_env}
</option>
))}
</select>
/>
{w.profile_id && (
<Btn onClick={() => apply.mutate()} disabled={apply.isPending} primary>
Apply config & restart
@@ -389,20 +393,21 @@ export default function AdminWorkerDetailPage() {
.filter((other) => other.worker_type === w.worker_type && other.free_tier === w.free_tier)
.filter((other) => other.install_state === "installed")
.sort((a, b) => a.account_count - b.account_count);
const rewireOptions: SelectOption[] = [
{ value: "", label: "— pick a target worker —" },
...candidates.map((c) => ({
value: c.id,
label: `${c.name || c.id.slice(0, 8)} · ${c.account_count} accounts · ${c.ssh_host || c.ip_addr}`,
})),
];
return (
<div className="flex flex-wrap items-center gap-2">
<select
<SelectMenu
value={rewireTarget}
onChange={(e) => setRewireTarget(e.target.value)}
className="border rounded px-3 py-1.5 text-sm w-full sm:min-w-[18rem]"
>
<option value="">— pick a target worker —</option>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.id.slice(0, 8)} · {c.account_count} accounts · {c.ssh_host || c.ip_addr}
</option>
))}
</select>
onChange={(v) => setRewireTarget(v)}
options={rewireOptions}
className="w-full sm:min-w-[18rem]"
/>
<Btn onClick={rewireAll} disabled={!rewireTarget} primary>
Move all {w.account_count} account{w.account_count === 1 ? "" : "s"}
</Btn>
@@ -454,26 +459,26 @@ export default function AdminWorkerDetailPage() {
: "Drain target (not needed — this worker is empty)"
}
>
<select
<SelectMenu
value={convertDrain}
onChange={(e) => setConvertDrain(e.target.value)}
className="w-full border rounded px-3 py-1.5 text-sm"
>
<option value="">— pick a target —</option>
{(allWorkers.data?.data ?? [])
.filter((other) =>
other.id !== id &&
other.worker_type === "shared" &&
other.install_state === "installed",
)
.sort((a, b) => a.account_count - b.account_count)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.id.slice(0, 8)} · {c.account_count} accounts ·{" "}
{c.free_tier ? "free" : "premium"}
</option>
))}
</select>
onChange={(v) => setConvertDrain(v)}
options={[
{ value: "", label: "— pick a target —" },
...(allWorkers.data?.data ?? [])
.filter(
(other) =>
other.id !== id &&
other.worker_type === "shared" &&
other.install_state === "installed",
)
.sort((a, b) => a.account_count - b.account_count)
.map((c) => ({
value: c.id,
label: `${c.name || c.id.slice(0, 8)} · ${c.account_count} accounts · ${c.free_tier ? "free" : "premium"}`,
})),
]}
className="w-full"
/>
</Field>
<div className="flex gap-2 mt-3">
<Btn onClick={doConvertToDedicated} disabled={converting} primary>
+19 -12
View File
@@ -13,7 +13,7 @@
// from the purpose so the admin doesn't have to think about risk_pool and
// worker_type independently from "what is this worker for."
import { useState } from "react";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
@@ -27,6 +27,7 @@ import {
} from "@/lib/api/client/app/admin/workers";
import { assignWorkerProfile, listWorkerProfiles } from "@/lib/api/client/app/admin/credentials";
import type { CreateWorkerResponse } from "@/lib/api/models/app/admin/Worker";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import { API_URL } from "@/lib/information";
type Purpose = "shared" | "dedicated" | "risky";
@@ -81,6 +82,17 @@ export default function AdminAddWorkerWizard() {
const profiles = useQuery({ queryKey: ["admin", "profiles"], queryFn: listWorkerProfiles });
const allWorkers = useQuery({ queryKey: ["admin", "workers", "managed"], queryFn: listManagedWorkers });
const profileOptions = useMemo<SelectOption[]>(
() => [
{ value: "", label: "(use backend env defaults)" },
...(profiles.data?.data?.map((p) => ({
value: p.id,
label: `${p.name} · ${p.app_env}`,
})) ?? []),
],
[profiles.data?.data],
);
// Steps shown depending on purpose. Dedicated adds an Owner step.
const totalSteps = state.purpose === "dedicated" ? 5 : 4;
@@ -358,18 +370,13 @@ export default function AdminAddWorkerWizard() {
</div>
<div className="mt-3">
<label className={lbl}>Worker profile</label>
<select
<SelectMenu
value={state.profile_id}
onChange={(e) => setState((s) => ({ ...s, profile_id: e.target.value }))}
className={inp}
>
<option value="">(use backend env defaults)</option>
{profiles.data?.data?.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {p.app_env}
</option>
))}
</select>
onChange={(v) => setState((s) => ({ ...s, profile_id: v }))}
options={profileOptions}
placeholder="(use backend env defaults)"
className="w-full"
/>
<p className="text-slate-400 text-xs mt-1">
Supplies Kafka / Schema Registry / Redis / AWS keys at install time.
</p>
@@ -27,6 +27,7 @@ import {
import toast from "react-hot-toast";
import { Label, TextInput } from "@/components/ui/field";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import { useConfirm } from "@/hooks/context/confirm";
import useConnectionDetail from "@/lib/api/hooks/app/integrations/useConnectionDetail";
import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration";
@@ -342,6 +343,10 @@ function AddAutomation({
const [eventType, setEventType] = React.useState(
availableEvents.includes(REPLY_EVENT) ? REPLY_EVENT : availableEvents[0] ?? REPLY_EVENT,
);
const eventOptions = React.useMemo<SelectOption[]>(
() => availableEvents.map((ev) => ({ value: ev, label: EVENT_LABELS[ev] ?? ev })),
[availableEvents],
);
const [dest, setDest] = React.useState("");
const [intents, setIntents] = React.useState<string[]>([]);
const [minConf, setMinConf] = React.useState(0);
@@ -375,17 +380,13 @@ function AddAutomation({
<div className="rounded-md border border-sky-200 bg-sky-50/40 p-3 space-y-3">
<div>
<Label>When this happens</Label>
<select
<SelectMenu
value={eventType}
onChange={(e) => setEventType(e.target.value)}
className="w-full h-7 px-2 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
>
{availableEvents.map((ev) => (
<option key={ev} value={ev}>
{EVENT_LABELS[ev] ?? ev}
</option>
))}
</select>
onChange={setEventType}
options={eventOptions}
className="w-full"
aria-label="When this happens"
/>
</div>
{/* Reply-only filters */}
+13 -11
View File
@@ -3,9 +3,10 @@
// effective limit going up on the org; rejection surfaces with the
// admin's notes attached to the row.
import { useState } from "react";
import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import { Section, SectionShell } from "../_components/SectionShell";
import getCurrentOrganization from "@/lib/api/client/app/organizations/getCurrentOrganization";
import listLimitRequests from "@/lib/api/client/app/organizations/listLimitRequests";
@@ -51,6 +52,11 @@ export default function LimitsSettingsPage() {
const [requested, setRequested] = useState<string>("");
const [reason, setReason] = useState<string>("");
const fieldSelectOptions = useMemo<SelectOption[]>(
() => FIELD_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label })),
[],
);
const submit = useMutation({
mutationFn: () =>
submitLimitRequest(orgId!, {
@@ -108,17 +114,13 @@ export default function LimitsSettingsPage() {
<form onSubmit={onSubmit} className="space-y-3">
<div>
<label className="text-[12px] font-medium text-slate-700">Resource</label>
<select
<SelectMenu
value={field}
onChange={(e) => setField(e.target.value as LimitField)}
className="mt-1 block w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
>
{FIELD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
onChange={(v) => setField(v as LimitField)}
options={fieldSelectOptions}
className="mt-1 w-full"
aria-label="Resource"
/>
<p className="text-[11px] text-slate-500 mt-1">
{FIELD_OPTIONS.find((o) => o.value === field)?.hint}
</p>
@@ -27,6 +27,7 @@ import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import useCreateCampaign from "@/lib/api/hooks/app/campaigns/useCreateCampaign";
import { Label, TextInput } from "@/components/ui/field";
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask";
import TagSelector from "@/components/app/popup/select/TagSelector";
import { useUserProfile } from "@/hooks/context/user";
@@ -449,21 +450,26 @@ function ScheduleStep({
setEndTime: (v: string) => void;
}) {
const profile = useUserProfile();
const timezoneOptions = React.useMemo<SelectOption[]>(
() =>
(profile?.timezones || []).map((tz) => ({
value: tz.name,
label: tz.display_name,
})),
[profile?.timezones],
);
return (
<div className="space-y-5 max-w-[560px]">
<div>
<Label>Sending timezone</Label>
<select
<SelectMenu
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full h-7 px-2 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
>
{(profile?.timezones || []).map((tz) => (
<option key={tz.name} value={tz.name}>
{tz.display_name}
</option>
))}
</select>
onChange={(v) => setTimezone(v)}
options={timezoneOptions}
className="w-full"
placeholder="Select a timezone"
aria-label="Sending timezone"
/>
<p className="text-[11px] text-slate-400 mt-1">
Sends are scheduled in this zone. Worker IPs spread distribution naturally.
</p>
+99
View File
@@ -0,0 +1,99 @@
// SelectMenu — the app's themed dropdown, replacing native <select> everywhere.
// Built on the shared PopoverMenu primitives so it matches every other picker
// (slate border, sky accent, rounded-md, h-7 density) and never shows the OS
// dropdown chrome. Supports optional option groups (rendered as section labels)
// while preserving declared order.
//
// (Distinct from the unused shadcn/Radix `select.tsx`, which has a different
// theme + compound API.)
import React from "react";
import { ChevronDownIcon } from "lucide-react";
import {
PopoverMenu,
PopoverMenuTrigger,
PopoverMenuContent,
PopoverMenuItem,
} from "@/components/ui/popover-menu";
export interface SelectOption {
value: string;
label: string;
/** Optional section header. Consecutive options sharing a group are grouped. */
group?: string;
disabled?: boolean;
}
export function SelectMenu({
value,
onChange,
options,
placeholder = "Select…",
className,
minWidth = 220,
align = "start",
disabled,
"aria-label": ariaLabel,
}: {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
className?: string;
minWidth?: number;
align?: "start" | "end" | "center";
disabled?: boolean;
"aria-label"?: string;
}) {
const [open, setOpen] = React.useState(false);
const current = options.find((o) => o.value === value);
// Group consecutive options that share a `group`, preserving order.
const groups: { group?: string; items: SelectOption[] }[] = [];
for (const o of options) {
const last = groups[groups.length - 1];
if (last && last.group === o.group) last.items.push(o);
else groups.push({ group: o.group, items: [o] });
}
return (
<PopoverMenu open={open} onOpenChange={setOpen} align={align}>
<PopoverMenuTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={ariaLabel}
className={`h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-[12px] text-slate-700 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${className ?? ""}`}
>
<span className={`truncate flex-1 text-left ${current ? "" : "text-slate-400"}`}>
{current?.label ?? placeholder}
</span>
<ChevronDownIcon className="w-3 h-3 text-slate-400 shrink-0" />
</button>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={minWidth} className="max-h-72 overflow-y-auto">
{groups.map((grp, gi) => (
<React.Fragment key={gi}>
{grp.group && (
<div className="px-2 pt-1.5 pb-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
{grp.group}
</div>
)}
{grp.items.map((o) => (
<PopoverMenuItem
key={o.value}
disabled={o.disabled}
selected={o.value === value}
onSelect={() => onChange(o.value)}
>
{o.label}
</PopoverMenuItem>
))}
</React.Fragment>
))}
</PopoverMenuContent>
</PopoverMenu>
);
}
export default SelectMenu;