From bd75f34b75a8f6b8defb7ab24e94fd4fed8ebc5a Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 11:28:41 +0300 Subject: [PATCH 1/9] docs(rules): spec for searchable event-type picker Extract DiscordSelect's popover-based searchable shell into a reusable SearchableSelect and use it for the trigger event picker. Co-Authored-By: Claude Opus 4.8 --- ...26-07-09-searchable-event-picker-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-searchable-event-picker-design.md 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 `…` 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. From 2a11eca08744f196bf1cb9ae86322e2e7b8a808f Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 11:34:03 +0300 Subject: [PATCH 2/9] docs(rules): implementation plan for searchable event-type picker Co-Authored-By: Claude Opus 4.8 --- .../2026-07-09-searchable-event-picker.md | 729 ++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-searchable-event-picker.md 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..844b2d1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-searchable-event-picker.md @@ -0,0 +1,729 @@ +# 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`. +- **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 ( + + ); + } + + return ( + + + + + + +
+ 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 && ( + + )} + + {filtered.length === 0 ? ( +

+ {search ? noResultsLabel : emptyLabel} +

+ ) : ( + filtered.map((opt) => ( + + )) + )} +
+
+
+ ); +} +``` + +- [ ] **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 ` + + + + + {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. ✅ From fdc7850ca89e22c42285c207249ebea68b449dda Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 11:47:38 +0300 Subject: [PATCH 3/9] feat(dashboard): add reusable SearchableSelect component Adds a presentational searchable single-select built on Popover with label+keyword filtering, allowNone row, loading/error states, and Obsidian Engine design tokens. Wires up @testing-library/jest-dom via a vitest setup file so toBeInTheDocument matchers work across all client component tests. Co-Authored-By: Claude Sonnet 4.6 --- apps/dashboard/package.json | 1 + .../client/shared/ui/searchable-select.tsx | 200 ++++++++++++++++++ .../shared/ui/searchable-select.test.tsx | 111 ++++++++++ apps/dashboard/tests/setup.ts | 1 + apps/dashboard/vitest.config.ts | 1 + pnpm-lock.yaml | 60 ++++++ 6 files changed, 374 insertions(+) create mode 100644 apps/dashboard/src/client/shared/ui/searchable-select.tsx create mode 100644 apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx create mode 100644 apps/dashboard/tests/setup.ts 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/shared/ui/searchable-select.tsx b/apps/dashboard/src/client/shared/ui/searchable-select.tsx new file mode 100644 index 0000000..f9fe682 --- /dev/null +++ b/apps/dashboard/src/client/shared/ui/searchable-select.tsx @@ -0,0 +1,200 @@ +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 ( + + ); + } + + return ( + + + + + + +
+ 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 && ( + + )} + + {filtered.length === 0 ? ( +

+ {search ? noResultsLabel : emptyLabel} +

+ ) : ( + filtered.map((opt) => ( + + )) + )} +
+
+
+ ); +} 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..d4b6dd8 --- /dev/null +++ b/apps/dashboard/tests/client/shared/ui/searchable-select.test.tsx @@ -0,0 +1,111 @@ +// @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(); + }); +}); 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/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 From 4c750d0117c8049e8d48dcdc175285bfbce36c48 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 11:59:09 +0300 Subject: [PATCH 4/9] refactor(dashboard): back DiscordSelect with SearchableSelect --- .../src/client/shared/ui/discord-select.tsx | 176 ++---------------- .../client/shared/ui/discord-select.test.tsx | 52 ++++++ 2 files changed, 69 insertions(+), 159 deletions(-) create mode 100644 apps/dashboard/tests/client/shared/ui/discord-select.test.tsx 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 ( - - ); - } - return ( - - - - - - - {/* 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 && ( - - )} - - {filtered.length === 0 ? ( -

- {search - ? "No results found" - : isRole - ? "No roles available" - : "No channels available"} -

- ) : ( - filtered.map((opt) => ( - - )) - )} -
-
-
+ ); } 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"); + }); +}); From 1f27f300591d2a03db0e0a0ae771aa273cc8cf8b Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 12:22:46 +0300 Subject: [PATCH 5/9] feat(rules): searchable event-type picker in the trigger panel Co-Authored-By: Claude Sonnet 4.6 --- .../automation/workflow/NodeDetailPanel.tsx | 35 ++++++++++++------- packages/i18n/src/locales/en/rules.json | 2 ++ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx index 1163bb1..239c9e3 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"; @@ -19,6 +19,8 @@ import { SelectTrigger, SelectValue, } from "../../../shared/ui/select"; +import { SearchableSelect } from "../../../shared/ui/searchable-select"; +import { EVENT_ICONS } from "../lib/rule-icons"; import { ConditionsEditor } from "../components/ConditionsEditor"; import type { ActionConditions, @@ -153,6 +155,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 +208,14 @@ function TriggerPanel({ {t("panel.eventType")} ({t("common:labels.required")}) - + v && onEventTypeChange(v)} + placeholder={t("panel.selectEvent")} + searchPlaceholder={t("panel.searchEvent")} + noResultsLabel={t("panel.noEventResults")} + /> {eventType && constants.eventTypes[eventType] && (
diff --git a/packages/i18n/src/locales/en/rules.json b/packages/i18n/src/locales/en/rules.json index 6743abc..223646e 100644 --- a/packages/i18n/src/locales/en/rules.json +++ b/packages/i18n/src/locales/en/rules.json @@ -166,6 +166,8 @@ "variables": "Variables", "eventType": "Event Type", "selectEvent": "Select event...", + "searchEvent": "Search events...", + "noEventResults": "No events found", "actionType": "Action Type", "selectAction": "Select action...", "moveUp": "Move Up", From 517be33f3cf7a3acfffd07a03cc0dc37dadb572a Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 12:43:07 +0300 Subject: [PATCH 6/9] =?UTF-8?q?docs(rules):=20plan=20addendum=20=E2=80=94?= =?UTF-8?q?=20extend=20searchable=20picker=20to=20action/field/operator/ch?= =?UTF-8?q?annel/role=20selects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../2026-07-09-searchable-event-picker.md | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) diff --git a/docs/superpowers/plans/2026-07-09-searchable-event-picker.md b/docs/superpowers/plans/2026-07-09-searchable-event-picker.md index 844b2d1..daa0fe8 100644 --- a/docs/superpowers/plans/2026-07-09-searchable-event-picker.md +++ b/docs/superpowers/plans/2026-07-09-searchable-event-picker.md @@ -727,3 +727,379 @@ git commit -m "feat(rules): searchable event-type picker in the trigger panel" **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 `` 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 (`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 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. ✅ From 9012c885877d398eac5a1c7ce32c5706f6ea781c Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 12:54:08 +0300 Subject: [PATCH 7/9] feat(rules): searchable action-type pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace both action-type - - - - - {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 && ( @@ -461,18 +458,14 @@ function StepPanel({ {t("panel.actionType")} ({t("common:labels.required")}) - + v && handleTypeChange(v)} + placeholder={t("panel.selectAction")} + searchPlaceholder={t("panel.search")} + noResultsLabel={t("panel.noResults")} + /> {step.action.type && fields.length > 0 && ( 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/packages/i18n/src/locales/en/rules.json b/packages/i18n/src/locales/en/rules.json index 223646e..ef2c305 100644 --- a/packages/i18n/src/locales/en/rules.json +++ b/packages/i18n/src/locales/en/rules.json @@ -168,6 +168,8 @@ "selectEvent": "Select event...", "searchEvent": "Search events...", "noEventResults": "No events found", + "search": "Search...", + "noResults": "No results", "actionType": "Action Type", "selectAction": "Select action...", "moveUp": "Move Up", From bf193c1f391ad477e0a961b40787f6f7bc693c87 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 13:10:19 +0300 Subject: [PATCH 8/9] feat(rules): searchable condition field & operator pickers --- .../automation/workflow/NodeDetailPanel.tsx | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx index 9f26c48..20f186c 100644 --- a/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx +++ b/apps/dashboard/src/client/features/automation/workflow/NodeDetailPanel.tsx @@ -12,13 +12,6 @@ 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"; @@ -506,34 +499,26 @@ function StepPanel({
- + ({ 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")} + />
- + ({ 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")} + />
From 00975df23243243a080902147bc2d91a4c447657 Mon Sep 17 00:00:00 2001 From: Abdulkhalek Muhammad Date: Thu, 9 Jul 2026 13:21:27 +0300 Subject: [PATCH 9/9] feat(rules): searchable channel & role action-field pickers Extends SearchableSelect with id/aria-required forwarding for label association, converts ActionFields channel and role 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" && ( - + 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/shared/ui/searchable-select.tsx b/apps/dashboard/src/client/shared/ui/searchable-select.tsx index f9fe682..34fd7f9 100644 --- a/apps/dashboard/src/client/shared/ui/searchable-select.tsx +++ b/apps/dashboard/src/client/shared/ui/searchable-select.tsx @@ -27,6 +27,8 @@ export interface SearchableSelectProps { noResultsLabel?: string; searchPlaceholder?: string; className?: string; + id?: string; + required?: boolean; } export function SearchableSelect({ @@ -44,6 +46,8 @@ export function SearchableSelect({ noResultsLabel = "No results found", searchPlaceholder = "Search...", className, + id, + required, }: SearchableSelectProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -91,6 +95,8 @@ export function SearchableSelect({