diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 30b2cfc..98ac615 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -71,6 +71,7 @@ "devDependencies": { "@playwright/test": "^1.49.0", "@tailwindcss/vite": "^4.1.7", + "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/react": "^19.1.6", diff --git a/apps/dashboard/src/client/features/automation/components/ActionFields.tsx b/apps/dashboard/src/client/features/automation/components/ActionFields.tsx index baecac5..7d2b0f8 100644 --- a/apps/dashboard/src/client/features/automation/components/ActionFields.tsx +++ b/apps/dashboard/src/client/features/automation/components/ActionFields.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { Icon } from "../../../shared/components/Icon"; +import { SearchableSelect } from "../../../shared/ui/searchable-select"; import { Label } from "../../../shared/ui/label"; import { Input } from "../../../shared/ui/input"; import { Textarea } from "../../../shared/ui/textarea"; @@ -75,48 +76,41 @@ export function ActionFields({ {field.type === "channel" && ( - onChange(field.key, v)} - > - - - - - {channels - .filter((c) => c.type === 0 || c.type === 2) - .map((c) => ( - - - - {c.name} - - - ))} - - + v && onChange(field.key, v)} + placeholder={t("form.selectChannel")} + searchPlaceholder={t("form.search")} + noResultsLabel={t("form.noResults")} + options={channels + .filter((c) => c.type === 0 || c.type === 2) + .map((c) => ({ + value: c.id, + label: c.name, + icon: ( + + ), + }))} + /> )} {field.type === "role" && ( - onChange(field.key, v)} - > - - - - - {roles.map((r) => ( - - {r.name} - - ))} - - + v && onChange(field.key, v)} + placeholder={t("form.selectRole")} + searchPlaceholder={t("form.search")} + noResultsLabel={t("form.noResults")} + options={roles.map((r) => ({ value: r.id, label: r.name }))} + /> )} {field.type === "text" && ( diff --git a/apps/dashboard/src/client/features/automation/lib/action-options.tsx b/apps/dashboard/src/client/features/automation/lib/action-options.tsx new file mode 100644 index 0000000..82418fc --- /dev/null +++ b/apps/dashboard/src/client/features/automation/lib/action-options.tsx @@ -0,0 +1,16 @@ +import { Icon } from "../../../shared/components/Icon"; +import type { SearchableSelectOption } from "../../../shared/ui/searchable-select"; +import type { Constants } from "../../../shared/lib/schemas"; +import { ACTION_ICONS } from "./rule-icons"; + +/** Map the action-type constants into SearchableSelect options (icon + description as search keywords). */ +export function buildActionTypeOptions( + actionTypes: Constants["actionTypes"], +): SearchableSelectOption[] { + return Object.entries(actionTypes).map(([value, info]) => ({ + value, + label: info.label, + keywords: info.description, + icon: , + })); +} diff --git a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx index 1163bb1..20f186c 100644 --- a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx +++ b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useChannels } from "../../../shared/hooks/useChannels"; @@ -12,13 +12,9 @@ import { Badge } from "../../../shared/ui/badge"; import { Icon } from "../../../shared/components/Icon"; import { ScrollArea } from "../../../shared/ui/scroll-area"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../../shared/ui/tabs"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../../shared/ui/select"; +import { SearchableSelect } from "../../../shared/ui/searchable-select"; +import { EVENT_ICONS } from "../lib/rule-icons"; +import { buildActionTypeOptions } from "../lib/action-options"; import { ConditionsEditor } from "../components/ConditionsEditor"; import type { ActionConditions, @@ -153,6 +149,17 @@ function TriggerPanel({ const { t } = useTranslation(["rules", "common"]); const variables = eventType ? (constants.eventTypeVariables[eventType] ?? []) : []; + const eventOptions = useMemo( + () => + Object.entries(constants.eventTypes).map(([value, info]) => ({ + value, + label: info.label, + keywords: info.description, + icon: , + })), + [constants.eventTypes], + ); + const conditionCount = (conditions.channelIds?.length ?? 0) + (conditions.roleIds?.length ?? 0) + @@ -195,18 +202,14 @@ function TriggerPanel({ {t("panel.eventType")} * ({t("common:labels.required")}) - - - - - - {Object.entries(constants.eventTypes).map(([key, info]) => ( - - {info.label} - - ))} - - + v && onEventTypeChange(v)} + placeholder={t("panel.selectEvent")} + searchPlaceholder={t("panel.searchEvent")} + noResultsLabel={t("panel.noEventResults")} + /> {eventType && constants.eventTypes[eventType] && ( @@ -287,18 +290,14 @@ function ActionPanel({ {t("panel.actionType")} * ({t("common:labels.required")}) - - - - - - {Object.entries(constants.actionTypes).map(([key, info]) => ( - - {info.label} - - ))} - - + v && handleTypeChange(v)} + placeholder={t("panel.selectAction")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> {action.type && fields.length > 0 && ( @@ -452,18 +451,14 @@ function StepPanel({ {t("panel.actionType")} * ({t("common:labels.required")}) - - - - - - {Object.entries(constants.actionTypes).map(([key, info]) => ( - - {info.label} - - ))} - - + v && handleTypeChange(v)} + placeholder={t("panel.selectAction")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> {step.action.type && fields.length > 0 && ( @@ -504,34 +499,26 @@ function StepPanel({ {t("panel.field")} - updateCondition({ field: v as StepConditionConfig["field"] })}> - - - - - {CONDITION_FIELDS.map((f) => ( - - {t(f.labelKey)} - - ))} - - + ({ value: f.value, label: t(f.labelKey) }))} + value={step.condition.field || null} + onValueChange={(v) => v && updateCondition({ field: v as StepConditionConfig["field"] })} + placeholder={t("panel.selectField")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> {t("panel.operator")} - updateCondition({ operator: v as StepConditionConfig["operator"] })}> - - - - - {CONDITION_OPERATORS.map((o) => ( - - {t(o.labelKey)} - - ))} - - + ({ value: o.value, label: t(o.labelKey) }))} + value={step.condition.operator || null} + onValueChange={(v) => v && updateCondition({ operator: v as StepConditionConfig["operator"] })} + placeholder={t("panel.selectOperator")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> diff --git a/apps/dashboard/src/client/shared/ui/discord-select.tsx b/apps/dashboard/src/client/shared/ui/discord-select.tsx index ee84afe..eed7b6a 100644 --- a/apps/dashboard/src/client/shared/ui/discord-select.tsx +++ b/apps/dashboard/src/client/shared/ui/discord-select.tsx @@ -1,9 +1,7 @@ -import { useState, useRef, useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useChannels } from "../hooks/useChannels"; import { useRoles } from "../hooks/useRoles"; -import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -import { SelectSkeleton } from "./skeletons"; -import { cn } from "../lib/utils"; +import { SearchableSelect, type SearchableSelectOption } from "./searchable-select"; export type DiscordSelectType = "text" | "voice" | "category" | "any" | "role"; @@ -35,10 +33,6 @@ export function DiscordSelect({ disabled, className, }: DiscordSelectProps) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const searchRef = useRef(null); - const isRole = type === "role"; const { data: channels, @@ -54,10 +48,10 @@ export function DiscordSelect({ const isLoading = isRole ? roLoading : chLoading; const isError = isRole ? roError : chError; - const options: { id: string; label: string }[] = useMemo( + const options: SearchableSelectOption[] = useMemo( () => isRole - ? (roles ?? []).map((r) => ({ id: r.id, label: `β ${r.name}` })) + ? (roles ?? []).map((r) => ({ value: r.id, label: `β ${r.name}` })) : (channels ?? []) .filter((c) => { if (type === "text") return c.type === 0; @@ -65,158 +59,22 @@ export function DiscordSelect({ if (type === "category") return c.type === 4; return c.type === 0 || c.type === 2; }) - .map((c) => ({ id: c.id, label: channelLabel(c.name, c.type) })), + .map((c) => ({ value: c.id, label: channelLabel(c.name, c.type) })), [isRole, roles, channels, type], ); - const filtered = useMemo(() => { - if (!search) return options; - const q = search.toLowerCase(); - return options.filter((o) => o.label.toLowerCase().includes(q)); - }, [options, search]); - - const selectedLabel = options.find((o) => o.id === value)?.label; - const defaultPlaceholder = isRole ? "Select a role" : "Select a channel"; - - // Focus search input when popover opens - useEffect(() => { - if (open) { - setSearch(""); - requestAnimationFrame(() => searchRef.current?.focus()); - } - }, [open]); - - if (isLoading) { - return ; - } - - if (isError) { - return ( - - Failed to load - - ); - } - return ( - - - - - {selectedLabel ?? placeholder ?? defaultPlaceholder} - - - - - - - - - {/* Search input */} - - setSearch(e.target.value)} - placeholder="Search..." - className="w-full rounded-sm bg-surface-lowest px-2.5 py-1.5 text-sm text-text placeholder:text-outline focus:outline-none" - /> - - - {/* Options list */} - - {allowNone && ( - { - onValueChange(null); - setOpen(false); - }} - > - None - - )} - - {filtered.length === 0 ? ( - - {search - ? "No results found" - : isRole - ? "No roles available" - : "No channels available"} - - ) : ( - filtered.map((opt) => ( - { - onValueChange(opt.id); - setOpen(false); - }} - > - {opt.label} - {opt.id === value && ( - - - - )} - - )) - )} - - - + ); } diff --git a/apps/dashboard/src/client/shared/ui/searchable-select.tsx b/apps/dashboard/src/client/shared/ui/searchable-select.tsx new file mode 100644 index 0000000..34fd7f9 --- /dev/null +++ b/apps/dashboard/src/client/shared/ui/searchable-select.tsx @@ -0,0 +1,206 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { SelectSkeleton } from "./skeletons"; +import { cn } from "../lib/utils"; + +export interface SearchableSelectOption { + value: string; + label: string; + /** Extra text folded into the search match (e.g. an event description). */ + keywords?: string; + /** Optional leading icon, rendered per-row and on the trigger. */ + icon?: ReactNode; +} + +export interface SearchableSelectProps { + options: SearchableSelectOption[]; + value: string | null; + onValueChange: (value: string | null) => void; + placeholder?: string; + allowNone?: boolean; + noneLabel?: string; + disabled?: boolean; + loading?: boolean; + error?: boolean; + errorLabel?: string; + emptyLabel?: string; + noResultsLabel?: string; + searchPlaceholder?: string; + className?: string; + id?: string; + required?: boolean; +} + +export function SearchableSelect({ + options, + value, + onValueChange, + placeholder, + allowNone, + noneLabel = "None", + disabled, + loading, + error, + errorLabel = "Failed to load", + emptyLabel = "No options available", + noResultsLabel = "No results found", + searchPlaceholder = "Search...", + className, + id, + required, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + + const filtered = useMemo(() => { + if (!search) return options; + const q = search.toLowerCase(); + return options.filter((o) => + `${o.label} ${o.keywords ?? ""}`.toLowerCase().includes(q), + ); + }, [options, search]); + + const selected = options.find((o) => o.value === value); + + // Clear + focus the search box each time the popover opens. + useEffect(() => { + if (open) { + setSearch(""); + requestAnimationFrame(() => searchRef.current?.focus()); + } + }, [open]); + + if (loading) { + return ; + } + + if (error) { + return ( + + {errorLabel} + + ); + } + + return ( + + + + + {selected?.icon} + {selected?.label ?? placeholder} + + + + + + + + + + setSearch(e.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="w-full rounded-sm bg-surface-lowest px-2.5 py-1.5 text-sm text-text placeholder:text-outline focus:outline-none" + /> + + + + {allowNone && ( + { + onValueChange(null); + setOpen(false); + }} + > + {noneLabel} + + )} + + {filtered.length === 0 ? ( + + {search ? noResultsLabel : emptyLabel} + + ) : ( + filtered.map((opt) => ( + { + onValueChange(opt.value); + setOpen(false); + }} + > + + {opt.icon} + {opt.label} + + {opt.value === value && ( + + + + )} + + )) + )} + + + + ); +} diff --git a/apps/dashboard/tests/client/features/automation/action-options.test.tsx b/apps/dashboard/tests/client/features/automation/action-options.test.tsx new file mode 100644 index 0000000..fabe5ee --- /dev/null +++ b/apps/dashboard/tests/client/features/automation/action-options.test.tsx @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { isValidElement, type ReactElement } from "react"; +import { buildActionTypeOptions } from "../../../../src/client/features/automation/lib/action-options"; + +const actionTypes = { + sendMessage: { label: "Send Message", description: "Send a message to a channel" }, + mysteryAction: { label: "Mystery", description: "Unknown action" }, +}; + +describe("buildActionTypeOptions", () => { + it("maps value, label, and description into keywords", () => { + const opts = buildActionTypeOptions(actionTypes); + expect(opts).toHaveLength(2); + expect(opts[0]).toMatchObject({ + value: "sendMessage", + label: "Send Message", + keywords: "Send a message to a channel", + }); + }); + + it("uses ACTION_ICONS for known types and falls back to 'bolt' for unknown", () => { + const opts = buildActionTypeOptions(actionTypes); + const send = opts.find((o) => o.value === "sendMessage")!; + const mystery = opts.find((o) => o.value === "mysteryAction")!; + expect(isValidElement(send.icon)).toBe(true); + expect((send.icon as ReactElement<{ name: string }>).props.name).toBe("chat"); + expect((mystery.icon as ReactElement<{ name: string }>).props.name).toBe("bolt"); + }); +}); diff --git a/apps/dashboard/tests/client/shared/ui/discord-select.test.tsx b/apps/dashboard/tests/client/shared/ui/discord-select.test.tsx new file mode 100644 index 0000000..60277ee --- /dev/null +++ b/apps/dashboard/tests/client/shared/ui/discord-select.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// DiscordSelect pulls its options from these hooks; mock them with static data. +vi.mock("../../../../src/client/shared/hooks/useChannels", () => ({ + useChannels: () => ({ + data: [ + { id: "1", name: "general", type: 0 }, + { id: "2", name: "voice-chat", type: 2 }, + ], + isLoading: false, + isError: false, + }), +})); +vi.mock("../../../../src/client/shared/hooks/useRoles", () => ({ + useRoles: () => ({ data: [], isLoading: false, isError: false }), +})); + +import { DiscordSelect } from "../../../../src/client/shared/ui/discord-select"; + +beforeAll(() => { + if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +describe("DiscordSelect", () => { + it("lists only text channels for type='text' and filters by search", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button")); + expect(screen.getByText("# general")).toBeInTheDocument(); + expect(screen.queryByText("π voice-chat")).not.toBeInTheDocument(); + await user.type(screen.getByPlaceholderText("Search..."), "gen"); + expect(screen.getByText("# general")).toBeInTheDocument(); + }); + + it("emits the channel id on select", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("# general")); + expect(onValueChange).toHaveBeenCalledWith("1"); + }); +}); diff --git a/apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx b/apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx new file mode 100644 index 0000000..008e2e5 --- /dev/null +++ b/apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + SearchableSelect, + type SearchableSelectOption, +} from "../../../../src/client/shared/ui/searchable-select"; + +beforeAll(() => { + if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +const options: SearchableSelectOption[] = [ + { value: "memberJoin", label: "Member Join", keywords: "when a new member joins" }, + { value: "messageDeleted", label: "Message Deleted", keywords: "when a message is deleted" }, + { value: "boostStart", label: "Boost Start", keywords: "when a member starts boosting" }, +]; + +function setup(props: Partial> = {}) { + const onValueChange = vi.fn(); + render( + , + ); + return { onValueChange }; +} + +describe("SearchableSelect", () => { + it("shows the placeholder when nothing is selected", () => { + setup(); + expect(screen.getByText("Select event")).toBeInTheDocument(); + }); + + it("opens and lists all options on trigger click", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + expect(screen.getByText("Member Join")).toBeInTheDocument(); + expect(screen.getByText("Message Deleted")).toBeInTheDocument(); + expect(screen.getByText("Boost Start")).toBeInTheDocument(); + }); + + it("filters options by label as the user types", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + await user.type(screen.getByPlaceholderText("Search events"), "boost"); + expect(screen.getByText("Boost Start")).toBeInTheDocument(); + expect(screen.queryByText("Member Join")).not.toBeInTheDocument(); + }); + + it("filters options by keywords, not just label", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + // "joins" appears only in Member Join's keywords, not its label. + await user.type(screen.getByPlaceholderText("Search events"), "joins"); + expect(screen.getByText("Member Join")).toBeInTheDocument(); + expect(screen.queryByText("Boost Start")).not.toBeInTheDocument(); + }); + + it("shows the no-results label when nothing matches", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + await user.type(screen.getByPlaceholderText("Search events"), "zzzzz"); + expect(screen.getByText("No events found")).toBeInTheDocument(); + }); + + it("calls onValueChange with the value and closes on select", async () => { + const user = userEvent.setup(); + const { onValueChange } = setup(); + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Message Deleted")); + expect(onValueChange).toHaveBeenCalledWith("messageDeleted"); + expect(screen.queryByPlaceholderText("Search events")).not.toBeInTheDocument(); + }); + + it("renders the allowNone row and emits null when picked", async () => { + const user = userEvent.setup(); + const { onValueChange } = setup({ allowNone: true, noneLabel: "None", value: "memberJoin" }); + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("None")); + expect(onValueChange).toHaveBeenCalledWith(null); + }); + + it("renders the error label instead of the trigger when error", () => { + setup({ error: true, errorLabel: "Failed to load" }); + expect(screen.getByText("Failed to load")).toBeInTheDocument(); + expect(screen.queryByText("Select event")).not.toBeInTheDocument(); + }); + + it("renders neither trigger text nor options when loading", () => { + setup({ loading: true }); + expect(screen.queryByText("Select event")).not.toBeInTheDocument(); + }); + + it("forwards id and aria-required to the trigger for label association", () => { + render( + , + ); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("id", "my-field"); + expect(trigger).toHaveAttribute("aria-required", "true"); + }); +}); diff --git a/apps/dashboard/tests/setup.ts b/apps/dashboard/tests/setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/apps/dashboard/tests/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts index 64499c0..7ac984b 100644 --- a/apps/dashboard/vitest.config.ts +++ b/apps/dashboard/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ }, test: { globals: true, + setupFiles: ["tests/setup.ts"], include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], alias: { "../../src/server/": resolve(__dirname, "src/server") + "/", diff --git a/docs/superpowers/plans/2026-07-09-searchable-event-picker.md b/docs/superpowers/plans/2026-07-09-searchable-event-picker.md new file mode 100644 index 0000000..daa0fe8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-searchable-event-picker.md @@ -0,0 +1,1105 @@ +# Searchable Event-Type Picker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the automation rule builder's trigger event picker a search box by extracting a reusable `SearchableSelect` component and consuming it from both `DiscordSelect` and the event picker. + +**Architecture:** Lift the Popover-based searchable shell out of `DiscordSelect` into a data-agnostic `SearchableSelect` (`shared/ui`). Refactor `DiscordSelect` to consume it (public API + appearance unchanged). Swap the trigger panel's plain Radix `` for `SearchableSelect`, fed by a `useMemo` mapping over `constants.eventTypes` with per-event icons. + +**Tech Stack:** React 19, TypeScript (strict), Radix Popover, Tailwind 4, react-i18next, Vitest + Testing Library + user-event (jsdom). + +## Global Constraints + +- Strict TypeScript β no `any`. +- Always use shadcn/ui wrappers from `apps/dashboard/src/client/shared/ui/` β do not use raw Radix when a wrapper exists. +- Do NOT change `DiscordSelect`'s exported `DiscordSelectProps` or its rendered text/appearance. +- No new dependencies (`cmdk`/Command is not installed and must not be added). +- No backend, schema, API, or `constants.eventTypes` changes. +- i18n: edit `packages/i18n/src/locales/en/rules.json`, then regenerate `dist` (the app serves `dist/locales`). Missing keys in other locales fall back to `en`. +- Every feature includes tests (mandatory). +- Test env markers: RTL component tests start with `// @vitest-environment jsdom` and stub `ResizeObserver` in `beforeAll` (Radix needs it under jsdom). +- Dashboard test command: `pnpm --filter @fluxcore/dashboard test `. Typecheck: `pnpm --filter @fluxcore/dashboard typecheck`. + +--- + +## File Structure + +- **Create** `apps/dashboard/src/client/shared/ui/searchable-select.tsx` β generic, presentational searchable single-select (Popover shell). +- **Create** `apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx` β thorough behavior tests. +- **Modify** `apps/dashboard/src/client/shared/ui/discord-select.tsx` β refactor to consume `SearchableSelect`. +- **Create** `apps/dashboard/tests/client/shared/ui/discord-select.test.tsx` β smoke test (no test exists today). +- **Modify** `apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx` β swap the trigger `` for `SearchableSelect`. +- **Modify** `packages/i18n/src/locales/en/rules.json` β add `panel.searchEvent`, `panel.noEventResults`; then regenerate `dist`. + +--- + +## Task 1: `SearchableSelect` component + +**Files:** +- Create: `apps/dashboard/src/client/shared/ui/searchable-select.tsx` +- Test: `apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx` + +**Interfaces:** +- Consumes: `Popover`, `PopoverContent`, `PopoverTrigger` from `./popover`; `SelectSkeleton` from `./skeletons`; `cn` from `../lib/utils`. +- Produces: + - `interface SearchableSelectOption { value: string; label: string; keywords?: string; icon?: React.ReactNode }` + - `function SearchableSelect(props: SearchableSelectProps): JSX.Element` where + `SearchableSelectProps = { options: SearchableSelectOption[]; value: string | null; onValueChange: (value: string | null) => void; placeholder?: string; allowNone?: boolean; noneLabel?: string; disabled?: boolean; loading?: boolean; error?: boolean; errorLabel?: string; emptyLabel?: string; noResultsLabel?: string; searchPlaceholder?: string; className?: string }` + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + SearchableSelect, + type SearchableSelectOption, +} from "../../../../src/client/shared/ui/searchable-select"; + +beforeAll(() => { + if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +const options: SearchableSelectOption[] = [ + { value: "memberJoin", label: "Member Join", keywords: "when a new member joins" }, + { value: "messageDeleted", label: "Message Deleted", keywords: "when a message is deleted" }, + { value: "boostStart", label: "Boost Start", keywords: "when a member starts boosting" }, +]; + +function setup(props: Partial> = {}) { + const onValueChange = vi.fn(); + render( + , + ); + return { onValueChange }; +} + +describe("SearchableSelect", () => { + it("shows the placeholder when nothing is selected", () => { + setup(); + expect(screen.getByText("Select event")).toBeInTheDocument(); + }); + + it("opens and lists all options on trigger click", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + expect(screen.getByText("Member Join")).toBeInTheDocument(); + expect(screen.getByText("Message Deleted")).toBeInTheDocument(); + expect(screen.getByText("Boost Start")).toBeInTheDocument(); + }); + + it("filters options by label as the user types", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + await user.type(screen.getByPlaceholderText("Search events"), "boost"); + expect(screen.getByText("Boost Start")).toBeInTheDocument(); + expect(screen.queryByText("Member Join")).not.toBeInTheDocument(); + }); + + it("filters options by keywords, not just label", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + // "joins" appears only in Member Join's keywords, not its label. + await user.type(screen.getByPlaceholderText("Search events"), "joins"); + expect(screen.getByText("Member Join")).toBeInTheDocument(); + expect(screen.queryByText("Boost Start")).not.toBeInTheDocument(); + }); + + it("shows the no-results label when nothing matches", async () => { + const user = userEvent.setup(); + setup(); + await user.click(screen.getByRole("button")); + await user.type(screen.getByPlaceholderText("Search events"), "zzzzz"); + expect(screen.getByText("No events found")).toBeInTheDocument(); + }); + + it("calls onValueChange with the value and closes on select", async () => { + const user = userEvent.setup(); + const { onValueChange } = setup(); + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("Message Deleted")); + expect(onValueChange).toHaveBeenCalledWith("messageDeleted"); + expect(screen.queryByPlaceholderText("Search events")).not.toBeInTheDocument(); + }); + + it("renders the allowNone row and emits null when picked", async () => { + const user = userEvent.setup(); + const { onValueChange } = setup({ allowNone: true, noneLabel: "None", value: "memberJoin" }); + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("None")); + expect(onValueChange).toHaveBeenCalledWith(null); + }); + + it("renders the error label instead of the trigger when error", () => { + setup({ error: true, errorLabel: "Failed to load" }); + expect(screen.getByText("Failed to load")).toBeInTheDocument(); + expect(screen.queryByText("Select event")).not.toBeInTheDocument(); + }); + + it("renders neither trigger text nor options when loading", () => { + setup({ loading: true }); + expect(screen.queryByText("Select event")).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @fluxcore/dashboard test tests/client/shared/ui/searchable-select.test.tsx` +Expected: FAIL β cannot resolve module `searchable-select` / `SearchableSelect is not defined`. + +- [ ] **Step 3: Write the component** + +Create `apps/dashboard/src/client/shared/ui/searchable-select.tsx`: + +```tsx +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; +import { SelectSkeleton } from "./skeletons"; +import { cn } from "../lib/utils"; + +export interface SearchableSelectOption { + value: string; + label: string; + /** Extra text folded into the search match (e.g. an event description). */ + keywords?: string; + /** Optional leading icon, rendered per-row and on the trigger. */ + icon?: ReactNode; +} + +export interface SearchableSelectProps { + options: SearchableSelectOption[]; + value: string | null; + onValueChange: (value: string | null) => void; + placeholder?: string; + allowNone?: boolean; + noneLabel?: string; + disabled?: boolean; + loading?: boolean; + error?: boolean; + errorLabel?: string; + emptyLabel?: string; + noResultsLabel?: string; + searchPlaceholder?: string; + className?: string; +} + +export function SearchableSelect({ + options, + value, + onValueChange, + placeholder, + allowNone, + noneLabel = "None", + disabled, + loading, + error, + errorLabel = "Failed to load", + emptyLabel = "No options available", + noResultsLabel = "No results found", + searchPlaceholder = "Search...", + className, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + + const filtered = useMemo(() => { + if (!search) return options; + const q = search.toLowerCase(); + return options.filter((o) => + `${o.label} ${o.keywords ?? ""}`.toLowerCase().includes(q), + ); + }, [options, search]); + + const selected = options.find((o) => o.value === value); + + // Clear + focus the search box each time the popover opens. + useEffect(() => { + if (open) { + setSearch(""); + requestAnimationFrame(() => searchRef.current?.focus()); + } + }, [open]); + + if (loading) { + return ; + } + + if (error) { + return ( + + {errorLabel} + + ); + } + + return ( + + + + + {selected?.icon} + {selected?.label ?? placeholder} + + + + + + + + + + setSearch(e.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="w-full rounded-sm bg-surface-lowest px-2.5 py-1.5 text-sm text-text placeholder:text-outline focus:outline-none" + /> + + + + {allowNone && ( + { + onValueChange(null); + setOpen(false); + }} + > + {noneLabel} + + )} + + {filtered.length === 0 ? ( + + {search ? noResultsLabel : emptyLabel} + + ) : ( + filtered.map((opt) => ( + { + onValueChange(opt.value); + setOpen(false); + }} + > + + {opt.icon} + {opt.label} + + {opt.value === value && ( + + + + )} + + )) + )} + + + + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @fluxcore/dashboard test tests/client/shared/ui/searchable-select.test.tsx` +Expected: PASS (9 tests). + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @fluxcore/dashboard typecheck` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/searchable-select.tsx apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx +git commit -m "feat(dashboard): add reusable SearchableSelect component" +``` + +--- + +## Task 2: Refactor `DiscordSelect` to consume `SearchableSelect` + +**Files:** +- Modify: `apps/dashboard/src/client/shared/ui/discord-select.tsx` (full rewrite of the component body; keep the file's public exports) +- Test: `apps/dashboard/tests/client/shared/ui/discord-select.test.tsx` + +**Interfaces:** +- Consumes: `SearchableSelect`, `SearchableSelectOption` from `./searchable-select` (Task 1); `useChannels` from `../hooks/useChannels`; `useRoles` from `../hooks/useRoles`. +- Produces: unchanged public API β `type DiscordSelectType = "text" | "voice" | "category" | "any" | "role"`; `interface DiscordSelectProps { guildId; type; value: string | null; onValueChange: (value: string | null) => void; placeholder?; allowNone?; disabled?; className? }`; `function DiscordSelect(props): JSX.Element`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/client/shared/ui/discord-select.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// DiscordSelect pulls its options from these hooks; mock them with static data. +vi.mock("../../../../src/client/shared/hooks/useChannels", () => ({ + useChannels: () => ({ + data: [ + { id: "1", name: "general", type: 0 }, + { id: "2", name: "voice-chat", type: 2 }, + ], + isLoading: false, + isError: false, + }), +})); +vi.mock("../../../../src/client/shared/hooks/useRoles", () => ({ + useRoles: () => ({ data: [], isLoading: false, isError: false }), +})); + +import { DiscordSelect } from "../../../../src/client/shared/ui/discord-select"; + +beforeAll(() => { + if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } +}); + +describe("DiscordSelect", () => { + it("lists only text channels for type='text' and filters by search", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button")); + expect(screen.getByText("# general")).toBeInTheDocument(); + expect(screen.queryByText("π voice-chat")).not.toBeInTheDocument(); + await user.type(screen.getByPlaceholderText("Search..."), "gen"); + expect(screen.getByText("# general")).toBeInTheDocument(); + }); + + it("emits the channel id on select", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + await user.click(screen.getByRole("button")); + await user.click(screen.getByText("# general")); + expect(onValueChange).toHaveBeenCalledWith("1"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @fluxcore/dashboard test tests/client/shared/ui/discord-select.test.tsx` +Expected: PASS on the *current* implementation already (the behavior is unchanged). This is a characterization test β it locks in behavior the refactor must preserve. If it does not pass against the current code, fix the test before refactoring. + +> Note: because DiscordSelect already produces this behavior, this test guards the refactor rather than driving new code. That is the intended TDD role here β red is not expected; the test must stay green across Step 3. + +- [ ] **Step 3: Rewrite the component body** + +Replace the entire contents of `apps/dashboard/src/client/shared/ui/discord-select.tsx` with: + +```tsx +import { useMemo } from "react"; +import { useChannels } from "../hooks/useChannels"; +import { useRoles } from "../hooks/useRoles"; +import { SearchableSelect, type SearchableSelectOption } from "./searchable-select"; + +export type DiscordSelectType = "text" | "voice" | "category" | "any" | "role"; + +export interface DiscordSelectProps { + guildId: string; + type: DiscordSelectType; + value: string | null; + onValueChange: (value: string | null) => void; + placeholder?: string; + /** Adds a "None" option that passes null to onValueChange */ + allowNone?: boolean; + disabled?: boolean; + className?: string; +} + +function channelLabel(name: string, channelType: number): string { + if (channelType === 2) return `π ${name}`; + if (channelType === 4) return `π ${name}`; + return `# ${name}`; +} + +export function DiscordSelect({ + guildId, + type, + value, + onValueChange, + placeholder, + allowNone, + disabled, + className, +}: DiscordSelectProps) { + const isRole = type === "role"; + const { + data: channels, + isLoading: chLoading, + isError: chError, + } = useChannels(guildId, { enabled: !isRole }); + const { + data: roles, + isLoading: roLoading, + isError: roError, + } = useRoles(guildId, { enabled: isRole }); + + const isLoading = isRole ? roLoading : chLoading; + const isError = isRole ? roError : chError; + + const options: SearchableSelectOption[] = useMemo( + () => + isRole + ? (roles ?? []).map((r) => ({ value: r.id, label: `β ${r.name}` })) + : (channels ?? []) + .filter((c) => { + if (type === "text") return c.type === 0; + if (type === "voice") return c.type === 2; + if (type === "category") return c.type === 4; + return c.type === 0 || c.type === 2; + }) + .map((c) => ({ value: c.id, label: channelLabel(c.name, c.type) })), + [isRole, roles, channels, type], + ); + + return ( + + ); +} +``` + +- [ ] **Step 4: Run the test + full dashboard client suite** + +Run: `pnpm --filter @fluxcore/dashboard test tests/client/shared/ui/discord-select.test.tsx` +Expected: PASS (2 tests). + +Run: `pnpm --filter @fluxcore/dashboard test tests/client` +Expected: PASS β no regressions in any consumer of `DiscordSelect`. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @fluxcore/dashboard typecheck` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/discord-select.tsx apps/dashboard/tests/client/shared/ui/discord-select.test.tsx +git commit -m "refactor(dashboard): back DiscordSelect with SearchableSelect" +``` + +--- + +## Task 3: Searchable event picker in the trigger panel + i18n + +**Files:** +- Modify: `apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx` +- Modify: `packages/i18n/src/locales/en/rules.json` + +**Interfaces:** +- Consumes: `SearchableSelect` from `../../../shared/ui/searchable-select` (Task 1); `EVENT_ICONS` from `../lib/rule-icons`; existing `Icon`, `useMemo`, `constants.eventTypes`. +- Produces: no new exports β an internal UI change to `TriggerPanel`. + +- [ ] **Step 1: Add the i18n keys** + +In `packages/i18n/src/locales/en/rules.json`, inside the `"panel"` object, add two keys immediately after the `"selectEvent"` line (currently line 168): + +```json + "selectEvent": "Select event...", + "searchEvent": "Search events...", + "noEventResults": "No events found", +``` + +- [ ] **Step 2: Regenerate the served locales (`dist`)** + +The app serves `packages/i18n/dist/locales`, not `src`. Regenerate it: + +Run: `pnpm --filter @fluxcore/i18n build` +Expected: completes; `packages/i18n/dist/locales/en/rules.json` now contains `searchEvent` and `noEventResults`. + +Verify: + +Run: `grep -c "searchEvent\|noEventResults" packages/i18n/dist/locales/en/rules.json` +Expected: `2` + +- [ ] **Step 3: Update `NodeDetailPanel.tsx` imports** + +Change the React import (line 1) from: + +```tsx +import { useState } from "react"; +``` + +to: + +```tsx +import { useState, useMemo } from "react"; +``` + +Add these two imports alongside the existing imports (e.g. after the `Select` import block that ends at line 21): + +```tsx +import { SearchableSelect } from "../../../shared/ui/searchable-select"; +import { EVENT_ICONS } from "../lib/rule-icons"; +``` + +> Keep the existing `Select`/`SelectContent`/`SelectItem`/`SelectTrigger`/`SelectValue` import β `ActionPanel` and `StepPanel` still use it. + +- [ ] **Step 4: Build the event options and swap the picker** + +In `function TriggerPanel(...)`, just after the existing `const variables = ...` line (currently line 154), add: + +```tsx + const eventOptions = useMemo( + () => + Object.entries(constants.eventTypes).map(([value, info]) => ({ + value, + label: info.label, + keywords: info.description, + icon: , + })), + [constants.eventTypes], + ); +``` + +Then replace the event-type `` block (currently lines 198β209): + +```tsx + + + + + + {Object.entries(constants.eventTypes).map(([key, info]) => ( + + {info.label} + + ))} + + +``` + +with: + +```tsx + v && onEventTypeChange(v)} + placeholder={t("panel.selectEvent")} + searchPlaceholder={t("panel.searchEvent")} + noResultsLabel={t("panel.noEventResults")} + /> +``` + +- [ ] **Step 5: Typecheck** + +Run: `pnpm --filter @fluxcore/dashboard typecheck` +Expected: no errors. + +- [ ] **Step 6: Run the full dashboard client suite (regression)** + +Run: `pnpm --filter @fluxcore/dashboard test tests/client` +Expected: PASS β no regressions in the automation/rules UI. + +- [ ] **Step 7: Manual smoke (optional but recommended)** + +Start the dashboard (`pnpm dev:dashboard`), open a guild's Rules β add/edit a rule β open the trigger node. Confirm the event field is now a searchable dropdown: typing "boost" narrows to Boost Start/Boost End; each row shows its event icon; selecting one sets the trigger and shows its description below. + +- [ ] **Step 8: Commit** + +```bash +git add apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx packages/i18n/src/locales/en/rules.json packages/i18n/dist/locales/en/rules.json +git commit -m "feat(rules): searchable event-type picker in the trigger panel" +``` + +--- + +## Final verification + +- [ ] **Full suite:** `pnpm --filter @fluxcore/dashboard test` β all green. +- [ ] **Typecheck:** `pnpm --filter @fluxcore/dashboard typecheck` β clean. +- [ ] **i18n parity:** `dist/locales/en/rules.json` contains `searchEvent` + `noEventResults` (other locales fall back to `en`). + +--- + +## Self-Review + +**Spec coverage:** +- Generic `SearchableSelect` (spec Β§Components 1) β Task 1. β +- `DiscordSelect` refactor, API/appearance unchanged (spec Β§Components 2) β Task 2 (characterization test + `emptyLabel`/`errorLabel`/placeholder pass-throughs preserve strings). β +- Event picker integration with icons + description keywords (spec Β§Components 3) β Task 3 Steps 3β4. β +- `eventType || null` binding (spec self-review note) β Task 3 Step 4. β +- i18n keys + dist sync (spec Β§i18n) β Task 3 Steps 1β2. β +- Tests: thorough SearchableSelect + DiscordSelect smoke + regression (spec Β§Tests) β Tasks 1β3. β +- Constraints: no new deps, no backend/schema, `Select` import retained β honored. β + +**Type consistency:** `SearchableSelectOption`/`SearchableSelectProps` defined in Task 1 are used verbatim in Tasks 2β3. `value: string | null` flows consistently; Task 3 adapts `eventType` (`string`) via `eventType || null` and `(v) => v && onEventTypeChange(v)`. `onValueChange` signature identical everywhere. β + +**Placeholder scan:** No TBD/TODO; every code step shows complete code. β + +--- + +# ADDENDUM β Extend searchable picker to the remaining rule-builder selects + +**Added 2026-07-09** after the event picker (Tasks 1β3) shipped. Scope confirmed by the user: convert **action type (Γ2)**, **condition field + operator**, and **action-field channel + role** to `SearchableSelect`. The generic `field.options` enum select is intentionally left as a plain ``. + +## Addendum Global Constraints (in addition to the ones above) +- Reuse the existing `SearchableSelect` (Task 1); do not fork it. +- Preserve each picker's current behavior and a11y: labels, required markers, and (for channel/role fields) the `id`/`aria-required` association between `` and the control. +- Do NOT add `useMemo` inside `StepPanel`'s `if (step.type === "action" | "condition")` branches β those are conditional code paths and a hook there violates the Rules of Hooks. Map options **inline in the `options` prop** instead. (`ActionPanel` is unbranched, but for uniformity map inline there too.) +- i18n keys go in BOTH `src` and `dist` locale files (dist is a gitignored build artifact but the running app serves it). NodeDetailPanel pickers use the `rules` namespace β `packages/i18n/{src,dist}/locales/en/rules.json`. ActionFields uses the `common` namespace β `packages/i18n/{src,dist}/locales/en/common.json`. +- Docker test/typecheck commands (NOT `pnpm --filter`): + - Test file: `docker compose --profile bot run --rm --no-deps bot sh -c "pnpm install --no-frozen-lockfile && cd apps/dashboard && node_modules/.bin/vitest run "` + - Full client regression: same but `node_modules/.bin/vitest run tests/client` + - Typecheck: `docker compose --profile bot run --rm --no-deps bot sh -c "pnpm install --no-frozen-lockfile && cd apps/dashboard && node_modules/.bin/tsc -p tsconfig.client.json --noEmit"` + +--- + +## Task 4: Action-type pickers (Γ2) β SearchableSelect + shared options helper + +**Files:** +- Create: `apps/dashboard/src/client/features/automation/lib/action-options.tsx` +- Test: `apps/dashboard/tests/client/features/automation/action-options.test.tsx` +- Modify: `apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx` (ActionPanel ~L299-310, StepPanel action branch ~L464-475; add import) +- Modify: `packages/i18n/src/locales/en/rules.json` + `packages/i18n/dist/locales/en/rules.json` + +**Interfaces:** +- Consumes: `SearchableSelectOption` from `../../../shared/ui/searchable-select`; `Icon` from `../../../shared/components/Icon`; `ACTION_ICONS` from `./rule-icons`; `Constants` from `../../../shared/lib/schemas`. +- Produces: `function buildActionTypeOptions(actionTypes: Constants["actionTypes"]): SearchableSelectOption[]`. + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/client/features/automation/action-options.test.tsx`: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { isValidElement, type ReactElement } from "react"; +import { buildActionTypeOptions } from "../../../../../src/client/features/automation/lib/action-options"; + +const actionTypes = { + sendMessage: { label: "Send Message", description: "Send a message to a channel" }, + mysteryAction: { label: "Mystery", description: "Unknown action" }, +}; + +describe("buildActionTypeOptions", () => { + it("maps value, label, and description into keywords", () => { + const opts = buildActionTypeOptions(actionTypes); + expect(opts).toHaveLength(2); + expect(opts[0]).toMatchObject({ + value: "sendMessage", + label: "Send Message", + keywords: "Send a message to a channel", + }); + }); + + it("uses ACTION_ICONS for known types and falls back to 'bolt' for unknown", () => { + const opts = buildActionTypeOptions(actionTypes); + const send = opts.find((o) => o.value === "sendMessage")!; + const mystery = opts.find((o) => o.value === "mysteryAction")!; + expect(isValidElement(send.icon)).toBe(true); + expect((send.icon as ReactElement<{ name: string }>).props.name).toBe("chat"); + expect((mystery.icon as ReactElement<{ name: string }>).props.name).toBe("bolt"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (Docker): `... vitest run tests/client/features/automation/action-options.test.tsx` +Expected: FAIL β cannot resolve `action-options`. + +- [ ] **Step 3: Write the helper** + +Create `apps/dashboard/src/client/features/automation/lib/action-options.tsx`: + +```tsx +import { Icon } from "../../../shared/components/Icon"; +import type { SearchableSelectOption } from "../../../shared/ui/searchable-select"; +import type { Constants } from "../../../shared/lib/schemas"; +import { ACTION_ICONS } from "./rule-icons"; + +/** Map the action-type constants into SearchableSelect options (icon + description as search keywords). */ +export function buildActionTypeOptions( + actionTypes: Constants["actionTypes"], +): SearchableSelectOption[] { + return Object.entries(actionTypes).map(([value, info]) => ({ + value, + label: info.label, + keywords: info.description, + icon: , + })); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run (Docker): `... vitest run tests/client/features/automation/action-options.test.tsx` +Expected: PASS (2 tests). + +- [ ] **Step 5: Add i18n keys (both src + dist)** + +In BOTH `packages/i18n/src/locales/en/rules.json` and `packages/i18n/dist/locales/en/rules.json`, inside the `"panel"` object, add after the `"noEventResults"` line: + +```json + "noEventResults": "No events found", + "search": "Search...", + "noResults": "No results", +``` + +Verify: `grep -c '"search"\|"noResults"' packages/i18n/dist/locales/en/rules.json` β expect `2`. + +- [ ] **Step 6: Wire both action-type pickers** + +In `NodeDetailPanel.tsx`, add the import next to the existing `EVENT_ICONS`/rule-icons import block: + +```tsx +import { buildActionTypeOptions } from "../lib/action-options"; +``` + +In **ActionPanel**, replace the action-type `` block (the one with `placeholder={t("panel.selectAction")}` and `Object.entries(constants.actionTypes)`): + +```tsx + v && handleTypeChange(v)} + placeholder={t("panel.selectAction")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> +``` + +In **StepPanel** (the `if (step.type === "action")` branch), replace its action-type `` block identically but bound to that branch's `handleTypeChange`: + +```tsx + v && handleTypeChange(v)} + placeholder={t("panel.selectAction")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> +``` + +(Both `handleTypeChange` handlers already accept `(newType: string)`.) Keep the `Select`/`SelectItem`/etc. import β StepPanel's condition branch and ActionFields' generic enum still use it (until Task 5). + +- [ ] **Step 7: Typecheck + regression (Docker)** + +Run typecheck β clean. Run `... vitest run tests/client` β all green (adds action-options tests; existing 57 still pass). + +- [ ] **Step 8: Commit** + +```bash +git add apps/dashboard/src/client/features/automation/lib/action-options.tsx \ + apps/dashboard/tests/client/features/automation/action-options.test.tsx \ + apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx \ + packages/i18n/src/locales/en/rules.json +git commit -m "feat(rules): searchable action-type pickers" +``` +(`dist` locale is gitignored β do not add it; the key edit there is only for the running app.) + +--- + +## Task 5: Condition field + operator pickers β SearchableSelect + +**Files:** +- Modify: `apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx` (StepPanel `if (step.type === "condition")` branch β field ~L516-527, operator ~L532-543) + +**Interfaces:** +- Consumes: `SearchableSelect` (already imported); `CONDITION_FIELDS`, `CONDITION_OPERATORS` (module constants already in this file); `t` from the StepPanel `useTranslation(["rules","common"])`. +- Produces: none (internal UI change). Reuses `panel.search` / `panel.noResults` keys added in Task 4 (no new i18n). + +- [ ] **Step 1: Replace the field picker** + +In StepPanel's condition branch, replace the field `` block (`placeholder={t("panel.selectField")}`, mapping `CONDITION_FIELDS`) with: + +```tsx + ({ value: f.value, label: t(f.labelKey) }))} + value={step.condition.field || null} + onValueChange={(v) => v && updateCondition({ field: v as StepConditionConfig["field"] })} + placeholder={t("panel.selectField")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> +``` + +- [ ] **Step 2: Replace the operator picker** + +Replace the operator `` block (`placeholder={t("panel.selectOperator")}`, mapping `CONDITION_OPERATORS`) with: + +```tsx + ({ value: o.value, label: t(o.labelKey) }))} + value={step.condition.operator || null} + onValueChange={(v) => v && updateCondition({ operator: v as StepConditionConfig["operator"] })} + placeholder={t("panel.selectOperator")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> +``` + +(Options are mapped inline β no `useMemo`, because these render inside a conditional branch. The lists are 8 and 12 items; recomputing per render is negligible.) + +- [ ] **Step 3: Typecheck + regression (Docker)** + +Typecheck β clean. `... vitest run tests/client` β all green. + +- [ ] **Step 4: Commit** + +```bash +git add apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx +git commit -m "feat(rules): searchable condition field & operator pickers" +``` + +--- + +## Task 6: Extend `SearchableSelect` (id/aria-required) + ActionFields channel & role pickers + +**Files:** +- Modify: `apps/dashboard/src/client/shared/ui/searchable-select.tsx` (add `id` + `required` props, forward to trigger) +- Modify: `apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx` (one test for id/aria-required forwarding) +- Modify: `apps/dashboard/src/client/features/automation/components/ActionFields.tsx` (channel field ~L77-102, role field ~L104-120; add SearchableSelect import) +- Modify: `packages/i18n/src/locales/en/common.json` + `packages/i18n/dist/locales/en/common.json` + +**Interfaces:** +- Adds to `SearchableSelectProps`: `id?: string` and `required?: boolean`. The trigger `` gets `id={id}` and `aria-required={required || undefined}`. No behavior change for existing callers (both optional/undefined). + +- [ ] **Step 1: Extend SearchableSelect β add the failing test first** + +Append to `apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx` (inside the existing `describe`): + +```tsx + it("forwards id and aria-required to the trigger for label association", () => { + render( + , + ); + const trigger = screen.getByRole("button"); + expect(trigger).toHaveAttribute("id", "my-field"); + expect(trigger).toHaveAttribute("aria-required", "true"); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run (Docker): `... vitest run tests/client/shared/ui/searchable-select.test.tsx` +Expected: FAIL β trigger has no `id`/`aria-required` (the new props don't exist yet). + +- [ ] **Step 3: Add the props and forward them** + +In `apps/dashboard/src/client/shared/ui/searchable-select.tsx`, add to `SearchableSelectProps`: + +```tsx + id?: string; + required?: boolean; +``` + +Destructure them in the component signature (alongside the other props): add `id,` and `required,`. Then on the main trigger `` (the one inside `PopoverTrigger asChild`), add the two attributes: + +```tsx + ...` block with: + +```tsx + {field.type === "channel" && ( + v && onChange(field.key, v)} + placeholder={t("form.selectChannel")} + searchPlaceholder={t("form.search")} + noResultsLabel={t("form.noResults")} + options={channels + .filter((c) => c.type === 0 || c.type === 2) + .map((c) => ({ + value: c.id, + label: c.name, + icon: ( + + ), + }))} + /> + )} +``` + +Replace the `field.type === "role"` `...` block with: + +```tsx + {field.type === "role" && ( + v && onChange(field.key, v)} + placeholder={t("form.selectRole")} + searchPlaceholder={t("form.search")} + noResultsLabel={t("form.noResults")} + options={roles.map((r) => ({ value: r.id, label: r.name }))} + /> + )} +``` + +Keep the existing `Select`/`SelectContent`/`SelectItem`/`SelectTrigger`/`SelectValue` import β the generic `field.type === "select"` enum block still uses it. (`Icon` is already imported.) + +- [ ] **Step 7: Typecheck + regression (Docker)** + +Typecheck β clean. `... vitest run tests/client` β all green (searchable-select now 10 tests). + +- [ ] **Step 8: Commit** + +```bash +git add apps/dashboard/src/client/shared/ui/searchable-select.tsx \ + apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx \ + apps/dashboard/src/client/features/automation/components/ActionFields.tsx \ + packages/i18n/src/locales/en/common.json +git commit -m "feat(rules): searchable channel & role action-field pickers" +``` + +--- + +## Addendum Self-Review + +**Scope coverage:** action type Γ2 β Task 4; condition field + operator β Task 5; action-field channel + role β Task 6. Generic enum select intentionally excluded. β +**A11y:** channel/role `id`+`aria-required` preserved via the SearchableSelect extension (Task 6). β +**Rules of Hooks:** no `useMemo` inside StepPanel conditional branches β options mapped inline. β +**i18n:** `panel.search`/`panel.noResults` (rules) + `form.search`/`form.noResults` (common), both src+dist; dist gitignored, not committed. β +**Type consistency:** `buildActionTypeOptions` returns `SearchableSelectOption[]`; all `value` bindings use `X || null` and `(v) => v && handler(v)`, matching the established pattern. β +**Placeholder scan:** complete code in every step, no TBD. β diff --git a/docs/superpowers/specs/2026-07-09-searchable-event-picker-design.md b/docs/superpowers/specs/2026-07-09-searchable-event-picker-design.md new file mode 100644 index 0000000..34aea5c --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-searchable-event-picker-design.md @@ -0,0 +1,167 @@ +# Searchable Event-Type Picker β Design Spec + +**Date:** 2026-07-09 +**Status:** Approved +**Branch:** `feat/rules-searchable-event-picker` + +## Problem + +The automation rule builder's trigger panel picks which Discord event fires a +rule from a plain Radix `` dropdown +([NodeDetailPanel.tsx](../../../apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx), +lines 198β209). There are 18 event types today and the list keeps growing, but +the dropdown has no search β you scroll to find an event. + +The dashboard already has the pattern we want: +[discord-select.tsx](../../../apps/dashboard/src/client/shared/ui/discord-select.tsx) +is a Popover-based searchable picker (trigger button β auto-focused search input +β filtered list β checkmark + empty state). It is hardwired to channels/roles. + +## Goal + +Give the event-type picker a search box, by **extracting the searchable-picker +shell from `DiscordSelect` into a reusable, data-agnostic `SearchableSelect` +component** and consuming it from both `DiscordSelect` and the event picker. + +Chosen approach: **B β generalize into a reusable `SearchableSelect`.** +(Approach A was a bespoke `EventTypeSelect`; C was a search input inside Radix +`Select`, rejected because Radix Select hijacks keyboard focus/typeahead.) + +## Non-goals + +- No change to `DiscordSelect`'s public API or appearance. +- No new dependency (no `cmdk`/Command β it is not installed). +- No backend, schema, API, or `constants.eventTypes` changes. +- No i18n retrofit of `DiscordSelect` (it keeps its current hardcoded strings). + +## Components + +### 1. `SearchableSelect` (new) + +**File:** `apps/dashboard/src/client/shared/ui/searchable-select.tsx` + +Purely presentational. Knows nothing about channels, roles, or events. It is the +Popover shell lifted out of `DiscordSelect` verbatim (same Tailwind classes and +SVGs, so consumers look unchanged): trigger `` with selected +label/icon + chevron, `PopoverContent` sized to +`w-(--radix-popover-trigger-width)`, an auto-focused (RAF) search input that +clears on open, a `max-h-56` scrollable list with hover styles and a checkmark +on the selected row. + +```ts +interface SearchableSelectOption { + value: string; + label: string; // rendered + searched + keywords?: string; // extra searchable text (e.g. an event description) + icon?: React.ReactNode; // optional leading icon, shown per-row and on trigger +} + +interface SearchableSelectProps { + options: SearchableSelectOption[]; + value: string | null; + onValueChange: (value: string | null) => void; + placeholder?: string; + allowNone?: boolean; + noneLabel?: string; // default "None" + disabled?: boolean; + loading?: boolean; // renders + error?: boolean; // renders disabled error button + errorLabel?: string; // default "Failed to load" + emptyLabel?: string; // options list is empty (no query) + noResultsLabel?: string; // query matched nothing; default "No results found" + searchPlaceholder?: string; // default "Search..." + className?: string; +} +``` + +**Filter:** case-insensitive `includes` over `label + " " + (keywords ?? "")`. +**Selection:** clicking a row calls `onValueChange(value)` and closes the popover; +the `allowNone` row (when enabled) calls `onValueChange(null)` and closes. + +### 2. `DiscordSelect` (refactor) + +**File:** `apps/dashboard/src/client/shared/ui/discord-select.tsx` + +Keep its data logic (`useChannels`/`useRoles`, `channelLabel`, type filtering). +Replace the inline Popover/search/list JSX with ``: + +- Map channels/roles β `options` (`{ value: id, label }`, no `icon`, so the + emoji-prefixed labels render exactly as today). +- Pass `loading={isLoading}`, `error={isError}`, `allowNone`, `disabled`, + `placeholder`, `className`. +- Pass `emptyLabel={isRole ? "No roles available" : "No channels available"}` + and the existing `errorLabel` / search placeholder strings so its text and + look are byte-for-byte unchanged. + +Its exported `DiscordSelectProps` and behavior are untouched β no consumer changes. + +### 3. Event picker (integration) + +**File:** `apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx` +(`TriggerPanel`, lines 198β209) + +Replace the `β¦` block with: + +```tsx + v && onEventTypeChange(v)} + placeholder={t("panel.selectEvent")} + searchPlaceholder={t("panel.searchEvent")} + noResultsLabel={t("panel.noEventResults")} + options={eventOptions} +/> +``` + +where `eventOptions` is a `useMemo` over `constants.eventTypes`: + +```ts +const eventOptions = useMemo( + () => + Object.entries(constants.eventTypes).map(([value, info]) => ({ + value, + label: info.label, + keywords: info.description, + icon: , + })), + [constants.eventTypes], +); +``` + +`EVENT_ICONS` comes from +`apps/dashboard/src/client/features/automation/lib/rule-icons.ts` (same map +[RuleList](../../../apps/dashboard/src/client/features/automation/components/RuleList.tsx) +uses). The description box already rendered below the picker (lines 211β217) +stays as-is. `allowNone` is omitted (false) β the picker always yields a string. + +## i18n + +`TriggerPanel` is fully translated (`useTranslation(["rules", "common"])`), so the +two new user-facing strings get keys in the `rules` namespace: + +- `panel.searchEvent` β e.g. "Search eventsβ¦" +- `panel.noEventResults` β e.g. "No events found" + +Add them to `packages/i18n/src/locales/en/rules.json` **and sync `dist`** (the app +serves `dist/locales`; other locales fall back to `en`). + +## Tests (mandatory) + +- **New** `apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx` + (RTL + user-event, both already set up): + - opens the list on trigger click; + - renders all options; filters the list as the user types; shows + `noResultsLabel` when nothing matches; + - renders the `allowNone` row and emits `null` when picked; + - `loading` renders the skeleton; `error` renders the error button; + - selecting a row calls `onValueChange(value)` and closes the popover. +- **`DiscordSelect`**: check for an existing test; since we modify it, add a + smoke test (renders selected label, filters options) if none exists. +- **Regression**: keep `NodeDetailPanel` / rules tests green. + +## Scope summary + +1 new component (`searchable-select.tsx`) Β· `discord-select.tsx` refactored to +consume it (API unchanged) Β· 1 swap in `TriggerPanel` + a `useMemo` mapping Β· 2 +i18n keys (+ dist sync) Β· new `searchable-select` test + DiscordSelect smoke test. +No new dependencies. No backend/schema/API changes. diff --git a/packages/i18n/src/locales/en/common.json b/packages/i18n/src/locales/en/common.json index 8cb4b77..a967f7f 100644 --- a/packages/i18n/src/locales/en/common.json +++ b/packages/i18n/src/locales/en/common.json @@ -87,6 +87,8 @@ "selectChannel": "Select channel...", "selectRole": "Select role...", "select": "Select...", + "search": "Search...", + "noResults": "No results", "saving": "Saving...", "saveSettings": "Save Settings", "previous": "Previous", diff --git a/packages/i18n/src/locales/en/rules.json b/packages/i18n/src/locales/en/rules.json index 6743abc..ef2c305 100644 --- a/packages/i18n/src/locales/en/rules.json +++ b/packages/i18n/src/locales/en/rules.json @@ -166,6 +166,10 @@ "variables": "Variables", "eventType": "Event Type", "selectEvent": "Select event...", + "searchEvent": "Search events...", + "noEventResults": "No events found", + "search": "Search...", + "noResults": "No results", "actionType": "Action Type", "selectAction": "Select action...", "moveUp": "Move Up", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9129d0..da4e01f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.7 version: 4.2.1(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -393,6 +396,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1905,6 +1911,10 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} engines: {node: '>=18'} @@ -2250,6 +2260,9 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2388,6 +2401,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -2624,6 +2640,10 @@ packages: immer@11.1.4: resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2840,6 +2860,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -3174,6 +3198,10 @@ packages: react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -3357,6 +3385,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -3770,6 +3802,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -5131,6 +5165,15 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@babel/runtime': 7.29.2 @@ -5529,6 +5572,8 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 + css.escape@1.5.1: {} + csstype@3.2.3: {} d3-array@3.2.4: @@ -5659,6 +5704,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -5945,6 +5992,8 @@ snapshots: immer@11.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} ini@1.3.8: @@ -6123,6 +6172,8 @@ snapshots: mimic-response@3.1.0: optional: true + min-indent@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.3 @@ -6464,6 +6515,11 @@ snapshots: - '@types/react' - redux + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -6633,6 +6689,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: optional: true
- {search - ? "No results found" - : isRole - ? "No roles available" - : "No channels available"} -
+ {search ? noResultsLabel : emptyLabel} +