diff --git a/client/e2eTests/protoFleet/pages/miners.ts b/client/e2eTests/protoFleet/pages/miners.ts index 85df885f6..482e0600d 100644 --- a/client/e2eTests/protoFleet/pages/miners.ts +++ b/client/e2eTests/protoFleet/pages/miners.ts @@ -41,31 +41,59 @@ export class MinersPage extends BasePage { .toBeGreaterThanOrEqual(minerCount); } - private async filterMinersByModel(minerType: string) { - await this.page.getByTestId("filter-dropdown-Model").click(); - const popover = this.page.getByTestId("dropdown-filter-popover"); + private async openAddFilterPopover() { + await this.page.getByTestId("filter-nested-filters-meta").click(); + const popover = this.page.getByTestId("nested-dropdown-filter-popover"); await expect(popover).toBeVisible(); - await expect(popover).toHaveCSS("opacity", "1"); - await this.clickDropdownFilterOption(popover, [minerType]); - await popover.getByRole("button", { name: "Apply" }).click(); + return popover; + } + + private async openModelSubmenu(popover: Locator) { + await popover.getByTestId("nested-dropdown-filter-row-model").click(); + // Desktop renders a portaled side submenu; phone/tablet collapses options into the + // parent popover with a "back" header. Either way the option rows for the chosen + // category become visible — return whichever container holds them. + const desktopSubmenu = this.page.getByTestId("nested-dropdown-filter-submenu-model"); + const mobileBack = popover.getByTestId("nested-dropdown-filter-back"); + await expect(desktopSubmenu.or(mobileBack)).toBeVisible(); + if (await desktopSubmenu.isVisible().catch(() => false)) return desktopSubmenu; + return popover; + } + + private async dismissAddFilterPopover() { + // Toggle the trigger to close — the trigger is never covered by its own popover, so + // this is more reliable than clicking page chrome that may not exist or may be + // intercepted by the portal-fixed popover. + await this.page.getByTestId("filter-nested-filters-meta").click(); + const popover = this.page.getByTestId("nested-dropdown-filter-popover"); await expect(popover).toBeHidden(); } + private async filterMinersByModel(minerType: string) { + const popover = await this.openAddFilterPopover(); + const submenu = await this.openModelSubmenu(popover); + await this.clickDropdownFilterOption(submenu, [minerType]); + await this.dismissAddFilterPopover(); + } + async filterRigMiners() { await this.filterMinersByModel(PROTO_RIG_MODEL); await this.waitForAntminersToDisappear(); } async filterAllMinersExceptRig() { - await this.page.getByTestId("filter-dropdown-Model").click(); - const popover = this.page.getByTestId("dropdown-filter-popover"); - await expect(popover).toBeVisible(); - await expect(popover).toHaveCSS("opacity", "1"); - await popover.getByText("Select all", { exact: true }).click(); - await this.clickDropdownFilterOption(popover, [PROTO_RIG_MODEL]); - - await popover.getByRole("button", { name: "Apply" }).click(); - await expect(popover).toBeHidden(); + const popover = await this.openAddFilterPopover(); + const submenu = await this.openModelSubmenu(popover); + // Nested submenu has no select-all; toggle every non-rig option individually. + const optionRows = submenu.locator('[data-testid^="filter-option-"]'); + const count = await optionRows.count(); + const skipTestId = `filter-option-${PROTO_RIG_MODEL}`; + for (let i = 0; i < count; i++) { + const row = optionRows.nth(i); + const testId = await row.getAttribute("data-testid"); + if (testId !== skipTestId) await row.click(); + } + await this.dismissAddFilterPopover(); await this.waitForRigMinersToDisappear(); } @@ -813,12 +841,19 @@ export class MinersPage extends BasePage { } async validateActiveFilter(filterLabel: string) { - const activeFilterButton = this.page.locator(`[data-testid*="active-filter-"]`, { hasText: filterLabel }); + // Match the chip's editable summary button only — the outer chip wrapper also carries + // an `active-filter-*` testid, which would otherwise resolve two elements with the + // same text and trip Playwright's strict mode. + const activeFilterButton = this.page.locator('button[data-testid^="active-filter-"][data-testid$="-edit"]', { + hasText: filterLabel, + }); await expect(activeFilterButton).toBeVisible(); } async validateActiveFilterNotVisible(filterLabel: string) { - const activeFilterButton = this.page.locator(`[data-testid*="active-filter-"]`, { hasText: filterLabel }); + const activeFilterButton = this.page.locator('button[data-testid^="active-filter-"][data-testid$="-edit"]', { + hasText: filterLabel, + }); await expect(activeFilterButton).toHaveCount(0); } diff --git a/client/e2eTests/protoFleet/pages/racks.ts b/client/e2eTests/protoFleet/pages/racks.ts index 612082f96..81b4d3ce6 100644 --- a/client/e2eTests/protoFleet/pages/racks.ts +++ b/client/e2eTests/protoFleet/pages/racks.ts @@ -310,28 +310,105 @@ export class RacksPage extends BasePage { await this.clickButton("View grid"); } - async applyZoneFilter(zoneNames: string[]) { - await this.clickVisibleFilterDropdown("Zone"); - const popover = this.page.getByTestId("dropdown-filter-popover"); - await expect(popover).toBeVisible(); - - await popover.getByRole("button", { name: "Reset", exact: true }).click(); + private async getVisibleAddFilterTrigger(): Promise { + const triggers = this.page.getByTestId("filter-nested-add-filter"); + const count = await triggers.count(); + for (let i = 0; i < count; i++) { + const trigger = triggers.nth(i); + if (await trigger.isVisible().catch(() => false)) return trigger; + } + throw new Error("No visible Add Filter trigger found"); + } - for (const zoneName of zoneNames) { - await this.clickDropdownFilterOption(popover, zoneName); + private async openVisibleAddFilter() { + const trigger = await this.getVisibleAddFilterTrigger(); + await trigger.click(); + const popover = this.page.getByTestId("nested-dropdown-filter-popover"); + await expect(popover).toBeVisible(); + return popover; + } + + private async openZoneSubmenu(popover: Locator) { + await popover.getByTestId("nested-dropdown-filter-row-zone").click(); + // Desktop renders a portaled side submenu; phone/tablet collapses options into the + // parent popover with a "back" header. Either way the option rows for the chosen + // category become visible — return whichever container holds them. + const desktopSubmenu = this.page.getByTestId("nested-dropdown-filter-submenu-zone"); + const mobileBack = popover.getByTestId("nested-dropdown-filter-back"); + await expect(desktopSubmenu.or(mobileBack)).toBeVisible(); + if (await desktopSubmenu.isVisible().catch(() => false)) return desktopSubmenu; + return popover; + } + + private async dismissAddFilterPopover() { + // Toggle the trigger to close — the trigger is never covered by its own popover, so + // this is more reliable than clicking page chrome that may not exist or may be + // intercepted by the portal-fixed popover. + const trigger = await this.getVisibleAddFilterTrigger(); + await trigger.click(); + await expect(this.page.getByTestId("nested-dropdown-filter-popover")).toBeHidden(); + } + + private async setZoneSelection(target: string[]) { + // Open Add Filter, drill into Zone, and toggle each option to match the desired set. + // Reading the live submenu (which reflects current selection) avoids the race in + // editing an existing chip's popover while resetAndFetch is in flight. + const popover = await this.openVisibleAddFilter(); + const submenu = await this.openZoneSubmenu(popover); + const options = submenu.locator('[data-testid^="filter-option-"]'); + const count = await options.count(); + const wanted = new Set(target); + for (let i = 0; i < count; i++) { + const opt = options.nth(i); + const testId = await opt.getAttribute("data-testid"); + if (!testId) continue; + const optionId = testId.replace(/^filter-option-/, ""); + const isChecked = await opt + .locator('input[type="checkbox"]') + .isChecked() + .catch(() => false); + if (isChecked !== wanted.has(optionId)) { + await opt.click(); + } } + await this.dismissAddFilterPopover(); + } - await popover.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(popover).toBeHidden(); + async applyZoneFilter(zoneNames: string[]) { + await this.setZoneSelection(zoneNames); } async toggleAllZoneFilters() { - await this.clickVisibleFilterDropdown("Zone"); - const popover = this.page.getByTestId("dropdown-filter-popover"); - await expect(popover).toBeVisible(); - await popover.getByText("Select all", { exact: true }).click(); - await popover.getByRole("button", { name: "Apply", exact: true }).click(); - await expect(popover).toBeHidden(); + // Toggle: if any zone is currently selected, clear; otherwise select all. + const popover = await this.openVisibleAddFilter(); + const submenu = await this.openZoneSubmenu(popover); + const options = submenu.locator('[data-testid^="filter-option-"]'); + const count = await options.count(); + let anyChecked = false; + for (let i = 0; i < count; i++) { + if ( + await options + .nth(i) + .locator('input[type="checkbox"]') + .isChecked() + .catch(() => false) + ) { + anyChecked = true; + break; + } + } + for (let i = 0; i < count; i++) { + const opt = options.nth(i); + const isChecked = await opt + .locator('input[type="checkbox"]') + .isChecked() + .catch(() => false); + if (isChecked === anyChecked) { + // anyChecked => clear all (uncheck checked); !anyChecked => select all (check unchecked). + await opt.click(); + } + } + await this.dismissAddFilterPopover(); } async selectGridSort(sortLabel: string) { diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx index 059078c81..d2a712a8d 100644 --- a/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx @@ -43,7 +43,7 @@ import { import { encodeSortToURL, parseSortFromURL } from "@/protoFleet/features/fleetManagement/utils/sortUrlParams"; import { useUsername } from "@/protoFleet/store"; -import { ChevronDown, LogoAlt, Slider } from "@/shared/assets/icons"; +import { ChevronDown, LogoAlt, Plus, Slider } from "@/shared/assets/icons"; import Button, { sizes, variants } from "@/shared/components/Button"; import Header from "@/shared/components/Header"; import List from "@/shared/components/List"; @@ -646,6 +646,7 @@ const MinerList = ({ () => ({ type: "dropdown", title: "Status", + pluralTitle: "statuses", value: "status", options: [ { id: deviceStatusFilterStates.hashing, label: "Hashing" }, @@ -662,6 +663,7 @@ const MinerList = ({ () => ({ type: "dropdown", title: "Issues", + pluralTitle: "issues", value: "issues", options: [ { id: componentIssues.controlBoard, label: "Control board issue" }, @@ -678,6 +680,7 @@ const MinerList = ({ () => ({ type: "dropdown", title: "Model", + pluralTitle: "models", value: "model", options: availableModels.map((model) => ({ id: model, label: model })), defaultOptionIds: [], @@ -689,6 +692,7 @@ const MinerList = ({ () => ({ type: "dropdown", title: "Groups", + pluralTitle: "groups", value: "group", options: availableGroups.map((g) => ({ id: String(g.id), label: g.label })), defaultOptionIds: [], @@ -700,6 +704,7 @@ const MinerList = ({ () => ({ type: "dropdown", title: "Racks", + pluralTitle: "racks", value: "rack", options: availableRacks.map((r) => ({ id: String(r.id), label: r.label })), defaultOptionIds: [], @@ -711,6 +716,7 @@ const MinerList = ({ () => ({ type: "dropdown", title: "Firmware", + pluralTitle: "firmware versions", value: "firmware", options: availableFirmwareVersions.map((v) => ({ id: v, label: v })), defaultOptionIds: [], @@ -722,6 +728,7 @@ const MinerList = ({ () => ({ type: "dropdown", title: "Zones", + pluralTitle: "zones", value: "zone", options: availableZones.map((z) => ({ id: z, label: z })), defaultOptionIds: [], @@ -733,15 +740,11 @@ const MinerList = ({ () => [ { type: "nestedFilterDropdown", - title: "Filters", + title: "Add Filter", value: "filters-meta", + prefixIcon: , children: [statusFilter, modelFilter, zonesFilter, racksFilter, groupsFilter, firmwareFilter, issuesFilter], }, - statusFilter, - issuesFilter, - modelFilter, - groupsFilter, - racksFilter, ], [statusFilter, issuesFilter, modelFilter, groupsFilter, racksFilter, firmwareFilter, zonesFilter], ); diff --git a/client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx b/client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx index 9e8c3dbf7..d176d62cf 100644 --- a/client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx +++ b/client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx @@ -15,10 +15,10 @@ import GroupModal from "@/protoFleet/features/groupManagement/components/GroupMo import GroupNameCell from "@/protoFleet/features/groupManagement/components/GroupsTable/GroupNameCell"; import { useDeviceSetListState } from "@/protoFleet/hooks/useDeviceSetListState"; -import { Alert, DismissTiny, Groups } from "@/shared/assets/icons"; +import { Alert, Groups } from "@/shared/assets/icons"; import Button, { sizes, variants } from "@/shared/components/Button"; import Callout from "@/shared/components/Callout"; -import DropdownFilter from "@/shared/components/List/Filters/DropdownFilter"; +import FilterChipsBar from "@/shared/components/List/Filters/FilterChipsBar"; import ProgressCircular from "@/shared/components/ProgressCircular"; const GROUPS_PAGE_SIZE = 50; @@ -48,35 +48,29 @@ const GroupsPage = () => { resetAndFetch, } = useDeviceSetListState(listGroups, GROUPS_PAGE_SIZE, getErrorComponentTypes); - const handleIssuesChange = useCallback( - (issues: string[]) => { - setSelectedIssues(issues); - selectedIssuesRef.current = issues; + const handleFilterChange = useCallback( + (key: string, values: string[]) => { + if (key !== "issues") return; + setSelectedIssues(values); + selectedIssuesRef.current = values; resetAndFetch(); }, [resetAndFetch, selectedIssuesRef], ); - const handleRemoveIssue = useCallback( - (issueId: string) => { - const next = selectedIssues.filter((id) => id !== issueId); - setSelectedIssues(next); - selectedIssuesRef.current = next; - resetAndFetch(); - }, - [selectedIssues, resetAndFetch, selectedIssuesRef], + const filterChipsBarFilters = useMemo( + () => [ + { + key: "issues", + title: "Issues", + pluralTitle: "issues", + options: issueOptions, + selectedValues: selectedIssues, + }, + ], + [selectedIssues], ); - const activeFilterPills = useMemo(() => { - return selectedIssues - .map((issueId) => { - const issue = issueOptions.find((o) => o.id === issueId); - if (!issue) return null; - return { key: `issue-${issueId}`, label: issue.label, onRemove: () => handleRemoveIssue(issueId) }; - }) - .filter(Boolean) as { key: string; label: string; onRemove: () => void }[]; - }, [selectedIssues, handleRemoveIssue]); - const hasActiveFilters = selectedIssues.length > 0; const handleClearFilters = useCallback(() => { @@ -152,38 +146,20 @@ const GroupsPage = () => { <>

Groups

-
-
-
- -
-
- -
-
- {activeFilterPills.length > 0 ? ( -
- {activeFilterPills.map((pill) => ( - - ))} -
- ) : null} +
+ +
{error ? ( diff --git a/client/src/protoFleet/features/rackManagement/pages/RacksPage.tsx b/client/src/protoFleet/features/rackManagement/pages/RacksPage.tsx index 4e4b48192..c7327d911 100644 --- a/client/src/protoFleet/features/rackManagement/pages/RacksPage.tsx +++ b/client/src/protoFleet/features/rackManagement/pages/RacksPage.tsx @@ -18,10 +18,11 @@ import { mapRackToCardProps } from "@/protoFleet/features/rackManagement/utils/r import { useDeviceSetListState } from "@/protoFleet/hooks/useDeviceSetListState"; import { useFleetStore } from "@/protoFleet/store/useFleetStore"; -import { Alert, ChevronDown, DismissTiny, Racks } from "@/shared/assets/icons"; +import { Alert, ChevronDown, Racks } from "@/shared/assets/icons"; import Button, { sizes, variants } from "@/shared/components/Button"; import Callout from "@/shared/components/Callout"; import DropdownFilter from "@/shared/components/List/Filters/DropdownFilter"; +import FilterChipsBar from "@/shared/components/List/Filters/FilterChipsBar"; import ProgressCircular from "@/shared/components/ProgressCircular"; import SegmentedControl from "@/shared/components/SegmentedControl"; import { pushToast, STATUSES } from "@/shared/features/toaster"; @@ -95,61 +96,43 @@ const RacksPage = () => { fetchZones(); }, [fetchZones]); - const handleIssuesChange = useCallback( - (issues: string[]) => { - setSelectedIssues(issues); - selectedIssuesRef.current = issues; - resetAndFetch(); - }, - [resetAndFetch, selectedIssuesRef], - ); - - const handleZonesChange = useCallback( - (zones: string[]) => { - setSelectedZones(zones); - selectedZonesRef.current = zones; - resetAndFetch(); - }, - [resetAndFetch], - ); - - const handleRemoveZone = useCallback( - (zoneId: string) => { - const next = selectedZones.filter((id) => id !== zoneId); - setSelectedZones(next); - selectedZonesRef.current = next; - resetAndFetch(); + const handleFilterChange = useCallback( + (key: string, values: string[]) => { + if (key === "zone") { + setSelectedZones(values); + selectedZonesRef.current = values; + resetAndFetch(); + return; + } + if (key === "issues") { + setSelectedIssues(values); + selectedIssuesRef.current = values; + resetAndFetch(); + } }, - [selectedZones, resetAndFetch], + [resetAndFetch, selectedIssuesRef, selectedZonesRef], ); - const handleRemoveIssue = useCallback( - (issueId: string) => { - const next = selectedIssues.filter((id) => id !== issueId); - setSelectedIssues(next); - selectedIssuesRef.current = next; - resetAndFetch(); - }, - [selectedIssues, resetAndFetch, selectedIssuesRef], + const filterChipsBarFilters = useMemo( + () => [ + { + key: "zone", + title: "Zone", + pluralTitle: "zones", + options: allZones, + selectedValues: selectedZones, + }, + { + key: "issues", + title: "Issues", + pluralTitle: "issues", + options: issueOptions, + selectedValues: selectedIssues, + }, + ], + [allZones, selectedZones, selectedIssues], ); - const activeFilterPills = useMemo(() => { - const pills: { key: string; label: string; type: "zone" | "issue"; id: string }[] = []; - for (const zoneId of selectedZones) { - const z = allZones.find((l) => l.id === zoneId); - if (z) { - pills.push({ key: `zone-${zoneId}`, label: z.label, type: "zone", id: zoneId }); - } - } - for (const issueId of selectedIssues) { - const issue = issueOptions.find((o) => o.id === issueId); - if (issue) { - pills.push({ key: `issue-${issueId}`, label: issue.label, type: "issue", id: issueId }); - } - } - return pills; - }, [selectedZones, selectedIssues, allZones]); - const hasActiveFilters = selectedZones.length > 0 || selectedIssues.length > 0; const handleClearFilters = useCallback(() => { @@ -158,7 +141,7 @@ const RacksPage = () => { setSelectedIssues([]); selectedIssuesRef.current = []; resetAndFetch(); - }, [resetAndFetch, selectedIssuesRef]); + }, [resetAndFetch, selectedIssuesRef, selectedZonesRef]); const emptyStateRow: ReactNode = useMemo(() => { if (isLoading || totalCount > 0) return undefined; @@ -328,61 +311,46 @@ const RacksPage = () => { />
{/* Desktop layout — single row with toggle + filters left, buttons right */} -
-
- setRacksViewMode(key as "grid" | "list")} - /> - +
+ setRacksViewMode(key as "grid" | "list")} + /> + + {racksViewMode === "grid" ? ( - {racksViewMode === "grid" ? ( - - ) : null} -
-
{/* Filters — shown separately on tablet/phone */} -
- - + {racksViewMode === "grid" ? ( { /> ) : null}
- {activeFilterPills.length > 0 ? ( -
- {activeFilterPills.map((pill) => ( - - ))} -
- ) : null}
{error ? ( diff --git a/client/src/shared/components/List/Filters/FilterChip.tsx b/client/src/shared/components/List/Filters/FilterChip.tsx new file mode 100644 index 000000000..34dd34400 --- /dev/null +++ b/client/src/shared/components/List/Filters/FilterChip.tsx @@ -0,0 +1,159 @@ +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; + +import { type DropdownOption } from "./DropdownFilter"; +import DropdownFilterPopover from "./DropdownFilterPopover"; +import { DismissTiny } from "@/shared/assets/icons"; +import { PopoverProvider, usePopover } from "@/shared/components/Popover"; +import { minimalMargin } from "@/shared/components/Popover/constants"; +import { type Position, positions } from "@/shared/constants"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +const popoverViewportPadding = minimalMargin * 2; +const POPOVER_CHROME_BASE = 56; + +export type FilterChipProps = { + filterValue: string; + title: string; + pluralTitle?: string; + options: DropdownOption[]; + selectedIds: string[]; + onChange: (selectedIds: string[]) => void; + onClear: () => void; + onOpenChange?: (open: boolean) => void; +}; + +const FilterChipContent = ({ + filterValue, + title, + pluralTitle, + options, + selectedIds, + onChange, + onClear, + onOpenChange, +}: FilterChipProps) => { + const [showPopover, setShowPopoverState] = useState(false); + const { triggerRef } = usePopover(); + const setShowPopover = useCallback( + (next: boolean | ((prev: boolean) => boolean)) => { + setShowPopoverState((prev) => { + const value = typeof next === "function" ? next(prev) : next; + if (value !== prev) onOpenChange?.(value); + return value; + }); + }, + [onOpenChange], + ); + const { height: windowHeight } = useWindowDimensions(); + const popoverRef = useRef(null) as RefObject; + const [optionsMaxHeight, setOptionsMaxHeight] = useState(); + const [popoverPosition, setPopoverPosition] = useState(positions["bottom right"]); + + useEffect(() => { + if (!showPopover || !triggerRef.current) return; + + const updatePopoverLayout = () => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const viewportHeight = window.visualViewport?.height ?? windowHeight; + const spaceAbove = rect.top - popoverViewportPadding; + const spaceBelow = viewportHeight - rect.bottom - popoverViewportPadding; + const shouldOpenAbove = spaceAbove > spaceBelow; + const available = (shouldOpenAbove ? spaceAbove : spaceBelow) - POPOVER_CHROME_BASE; + setPopoverPosition(shouldOpenAbove ? positions["top right"] : positions["bottom right"]); + setOptionsMaxHeight(Math.max(available, 0)); + }; + + updatePopoverLayout(); + window.visualViewport?.addEventListener("resize", updatePopoverLayout); + return () => { + window.visualViewport?.removeEventListener("resize", updatePopoverLayout); + }; + }, [showPopover, triggerRef, windowHeight]); + + useClickOutside({ + ref: triggerRef, + onClickOutside: () => setShowPopover(false), + ignoreSelectors: [".popover-content"], + }); + + const handleToggleItem = useCallback( + (itemId: string) => { + const next = selectedIds.includes(itemId) ? selectedIds.filter((id) => id !== itemId) : [...selectedIds, itemId]; + onChange(next); + }, + [selectedIds, onChange], + ); + + const handleSelectAll = useCallback(() => { + onChange(selectedIds.length === options.length ? [] : options.map((option) => option.id)); + }, [selectedIds, options, onChange]); + + const allSelected = selectedIds.length > 0 && selectedIds.length === options.length; + const partiallySelected = selectedIds.length > 0 && selectedIds.length < options.length; + + const plural = pluralTitle ?? `${title}s`; + let summary: string; + if (selectedIds.length === 1) { + const match = options.find((option) => option.id === selectedIds[0]); + summary = match?.label ?? ""; + } else { + summary = `${selectedIds.length} ${plural}`; + } + + return ( +
+
+ + {title} + + + +
+ + {showPopover ? ( + onChange([])} + handleApply={() => setShowPopover(false)} + popoverRef={popoverRef} + optionsMaxHeight={optionsMaxHeight} + position={popoverPosition} + /> + ) : null} +
+ ); +}; + +const FilterChip = (props: FilterChipProps) => ( + + + +); + +export default FilterChip; diff --git a/client/src/shared/components/List/Filters/FilterChipsBar.tsx b/client/src/shared/components/List/Filters/FilterChipsBar.tsx new file mode 100644 index 000000000..ee7da2a47 --- /dev/null +++ b/client/src/shared/components/List/Filters/FilterChipsBar.tsx @@ -0,0 +1,88 @@ +import { type ReactNode, useCallback, useState } from "react"; + +import { type DropdownOption } from "./DropdownFilter"; +import FilterChip from "./FilterChip"; +import NestedDropdownFilter, { type FilterCategory } from "./NestedDropdownFilter"; +import { Plus } from "@/shared/assets/icons"; + +export type FilterChipsBarFilter = { + key: string; + title: string; + pluralTitle?: string; + options: DropdownOption[]; + selectedValues: string[]; +}; + +type FilterChipsBarProps = { + filters: FilterChipsBarFilter[]; + onChange: (key: string, selectedValues: string[]) => void; + onClearAll?: () => void; + triggerLabel?: string; + triggerPrefixIcon?: ReactNode; + triggerTestId?: string; +}; + +const FilterChipsBar = ({ + filters, + onChange, + onClearAll, + triggerLabel = "Add Filter", + triggerPrefixIcon = , + triggerTestId = "filter-nested-add-filter", +}: FilterChipsBarProps) => { + // Tracking the open chip keeps it mounted while the user toggles its last selection off + // — otherwise the chip unmounts mid-interaction and takes its popover with it. + const [openChipKey, setOpenChipKey] = useState(null); + + const fallbackClearAll = useCallback(() => { + filters.forEach((f) => { + if (f.selectedValues.length > 0) onChange(f.key, []); + }); + setOpenChipKey(null); + }, [filters, onChange]); + + const categories: FilterCategory[] = filters.map((f) => ({ + key: f.key, + label: f.title, + options: f.options, + selectedValues: f.selectedValues, + })); + + return ( + <> + {filters.map((f) => + f.selectedValues.length > 0 || openChipKey === f.key ? ( + onChange(f.key, ids)} + onClear={() => { + onChange(f.key, []); + setOpenChipKey((prev) => (prev === f.key ? null : prev)); + }} + onOpenChange={(open) => + setOpenChipKey((prev) => { + if (open) return f.key; + return prev === f.key ? null : prev; + }) + } + /> + ) : null, + )} + + + ); +}; + +export default FilterChipsBar; diff --git a/client/src/shared/components/List/Filters/Filters.test.tsx b/client/src/shared/components/List/Filters/Filters.test.tsx index c1d032ad9..5a1c8602f 100644 --- a/client/src/shared/components/List/Filters/Filters.test.tsx +++ b/client/src/shared/components/List/Filters/Filters.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import Filters from "./Filters"; import { testFilters, TestItem, testItems } from "@/shared/components/List/mocks/data"; @@ -215,8 +215,12 @@ describe("Filters", () => { />, ); - // The pill renders the option label even though no standalone "Firmware" trigger exists in the bar. - expect(screen.getByTestId("active-filter-firmware-v3.5.1")).toBeInTheDocument(); + // The chip renders even though no standalone "Firmware" trigger exists in the bar. + const chip = screen.getByTestId("active-filter-firmware"); + expect(chip).toBeInTheDocument(); + // Bifurcated chip: left half is the category title, right half summarizes selections. + expect(chip).toHaveTextContent("Firmware"); + expect(chip).toHaveTextContent("v3.5.1"); // No standalone "Firmware" filter trigger. expect(screen.queryByTestId("filter-dropdown-Firmware")).not.toBeInTheDocument(); // The nested-dropdown trigger is in the bar. @@ -258,10 +262,235 @@ describe("Filters", () => { />, ); - // Only one pill for `status=hashing` even though it lives in two filter sources. - const pills = screen.getAllByTestId(/^active-filter-status-/); - expect(pills).toHaveLength(1); - expect(pills[0]).toHaveAttribute("data-testid", "active-filter-status-hashing"); + // Only one chip for `status` even though it lives in two filter sources. + const chips = screen.getAllByTestId(/^active-filter-status$/); + expect(chips).toHaveLength(1); + expect(chips[0]).toHaveTextContent("Status"); + expect(chips[0]).toHaveTextContent("Hashing"); + }); + + it("renders one bifurcated chip per category showing the option label when one is selected", () => { + const handleFiltering = vi.fn(); + + const modelFilter = { + type: "dropdown" as const, + title: "Model", + pluralTitle: "models", + value: "model", + options: [ + { id: "S19", label: "S19" }, + { id: "S21", label: "S21" }, + ], + defaultOptionIds: [], + }; + + render( + + filterItems={[modelFilter]} + items={testItems} + onFilter={handleFiltering} + initialActiveFilters={{ + buttonFilters: [], + dropdownFilters: { model: ["S19"] }, + }} + />, + ); + + const chip = screen.getByTestId("active-filter-model"); + expect(chip).toHaveTextContent("Model"); + // Single selection shows the option label directly. + expect(within(chip).getByTestId("active-filter-model-edit")).toHaveTextContent("S19"); + }); + + it("summarizes the right side as `n plural` when multiple options are selected", () => { + const handleFiltering = vi.fn(); + + const statusFilter = { + type: "dropdown" as const, + title: "Status", + pluralTitle: "statuses", + value: "status", + options: [ + { id: "hashing", label: "Hashing" }, + { id: "offline", label: "Offline" }, + { id: "sleeping", label: "Sleeping" }, + ], + defaultOptionIds: [], + }; + + render( + + filterItems={[statusFilter]} + items={testItems} + onFilter={handleFiltering} + initialActiveFilters={{ + buttonFilters: [], + dropdownFilters: { status: ["hashing", "offline"] }, + }} + />, + ); + + const chip = screen.getByTestId("active-filter-status"); + expect(within(chip).getByTestId("active-filter-status-edit")).toHaveTextContent("2 statuses"); + }); + + it("clears every selected option in a category when the chip's clear icon is clicked", () => { + const handleFiltering = vi.fn(); + + const issuesFilter = { + type: "dropdown" as const, + title: "Issues", + pluralTitle: "issues", + value: "issues", + options: [ + { id: "control-board", label: "Control board issue" }, + { id: "hash-boards", label: "Hash board issue" }, + ], + defaultOptionIds: [], + }; + + render( + + filterItems={[issuesFilter]} + items={testItems} + onFilter={handleFiltering} + initialActiveFilters={{ + buttonFilters: [], + dropdownFilters: { issues: ["control-board", "hash-boards"] }, + }} + />, + ); + + fireEvent.click(screen.getByTestId("active-filter-issues-clear")); + + expect(screen.queryByTestId("active-filter-issues")).not.toBeInTheDocument(); + expect(handleFiltering).toHaveBeenCalledWith( + expect.objectContaining({ + dropdownFilters: expect.objectContaining({ issues: [] }), + }), + ); + }); + + it("opens an editable popover when the chip's right side is clicked and toggles options inline", async () => { + const handleFiltering = vi.fn(); + + const issuesFilter = { + type: "dropdown" as const, + title: "Issues", + pluralTitle: "issues", + value: "issues", + options: [ + { id: "control-board", label: "Control board issue" }, + { id: "hash-boards", label: "Hash board issue" }, + ], + defaultOptionIds: [], + }; + + render( + + filterItems={[issuesFilter]} + items={testItems} + onFilter={handleFiltering} + initialActiveFilters={{ + buttonFilters: [], + dropdownFilters: { issues: ["control-board"] }, + }} + />, + ); + + fireEvent.click(screen.getByTestId("active-filter-issues-edit")); + + await waitFor(() => { + expect(screen.getByTestId("filter-option-hash-boards")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("filter-option-hash-boards")); + + expect(handleFiltering).toHaveBeenCalledWith( + expect.objectContaining({ + dropdownFilters: expect.objectContaining({ + issues: ["control-board", "hash-boards"], + }), + }), + ); + }); + + it("keeps the chip and its popover mounted while the user clears every option from inside it", async () => { + const handleFiltering = vi.fn(); + + const issuesFilter = { + type: "dropdown" as const, + title: "Issues", + pluralTitle: "issues", + value: "issues", + options: [ + { id: "control-board", label: "Control board issue" }, + { id: "hash-boards", label: "Hash board issue" }, + ], + defaultOptionIds: [], + }; + + render( + + filterItems={[issuesFilter]} + items={testItems} + onFilter={handleFiltering} + initialActiveFilters={{ + buttonFilters: [], + dropdownFilters: { issues: ["control-board", "hash-boards"] }, + }} + />, + ); + + fireEvent.click(screen.getByTestId("active-filter-issues-edit")); + await waitFor(() => { + expect(screen.getByTestId("filter-option-control-board")).toBeInTheDocument(); + }); + + // Deselect every option one by one from inside the popover. Without the open-popover + // guard the chip would unmount on the toggle that drops the last selection because + // selectedIds becomes empty. + fireEvent.click(screen.getByTestId("filter-option-control-board")); + fireEvent.click(screen.getByTestId("filter-option-hash-boards")); + + expect(screen.getByTestId("active-filter-issues")).toBeInTheDocument(); + expect(screen.getByTestId("filter-option-control-board")).toBeInTheDocument(); + expect(screen.getByTestId("active-filter-issues-edit")).toHaveTextContent("0 issues"); + }); + + it("does not render a Select all row inside the chip's edit popover", async () => { + const handleFiltering = vi.fn(); + + const issuesFilter = { + type: "dropdown" as const, + title: "Issues", + pluralTitle: "issues", + value: "issues", + options: [ + { id: "control-board", label: "Control board issue" }, + { id: "hash-boards", label: "Hash board issue" }, + ], + defaultOptionIds: [], + }; + + render( + + filterItems={[issuesFilter]} + items={testItems} + onFilter={handleFiltering} + initialActiveFilters={{ + buttonFilters: [], + dropdownFilters: { issues: ["control-board"] }, + }} + />, + ); + + fireEvent.click(screen.getByTestId("active-filter-issues-edit")); + await waitFor(() => { + expect(screen.getByTestId("filter-option-control-board")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Select all")).not.toBeInTheDocument(); }); it("clears active filters when initialActiveFilters transitions to undefined", () => { @@ -287,7 +516,7 @@ describe("Filters", () => { />, ); - expect(screen.getByTestId("active-filter-status-hashing")).toBeInTheDocument(); + expect(screen.getByTestId("active-filter-status")).toBeInTheDocument(); // Parent clears the controlled prop — internal state should reset to defaults // so stale selections don't linger. @@ -300,6 +529,6 @@ describe("Filters", () => { />, ); - expect(screen.queryByTestId("active-filter-status-hashing")).not.toBeInTheDocument(); + expect(screen.queryByTestId("active-filter-status")).not.toBeInTheDocument(); }); }); diff --git a/client/src/shared/components/List/Filters/Filters.tsx b/client/src/shared/components/List/Filters/Filters.tsx index c7d1aab0c..0303b32c0 100644 --- a/client/src/shared/components/List/Filters/Filters.tsx +++ b/client/src/shared/components/List/Filters/Filters.tsx @@ -2,10 +2,10 @@ import { ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, us import clsx from "clsx"; import ButtonFilter from "./ButtonFilter"; -import DropdownFilter, { type DropdownOption } from "./DropdownFilter"; +import DropdownFilter from "./DropdownFilter"; +import FilterChip from "./FilterChip"; import NestedDropdownFilter, { type FilterCategory } from "./NestedDropdownFilter"; -import { DismissTiny } from "@/shared/assets/icons"; -import Button, { sizes, variants } from "@/shared/components/Button"; +import { sizes } from "@/shared/components/Button"; import { defaultListFilter } from "@/shared/components/List/constants"; import { ActiveFilters, type DropdownFilterItem, type FilterItem } from "@/shared/components/List/Filters/types"; @@ -20,8 +20,12 @@ type FilterProps = { initialActiveFilters?: ActiveFilters; }; -type ActiveDropdownFilterItem = DropdownOption & { +type ActiveDropdownFilterGroup = { filterValue: string; + title: string; + pluralTitle?: string; + options: DropdownFilterItem["options"]; + selectedIds: string[]; }; const Filters = ({ @@ -43,6 +47,9 @@ const Filters = ({ ); const [activeFilters, setActiveFilters] = useState(initialActiveFilters || defaultActiveFilters); + // Tracking the open chip keeps it mounted while the user toggles its last selection off + // — otherwise the chip unmounts mid-interaction and takes its popover with it. + const [openChipFilterValue, setOpenChipFilterValue] = useState(null); // Store onFilter in a ref to avoid re-running effects when the callback reference changes. // The callback changes when parent's items change (due to useCallback dependencies in List), @@ -64,6 +71,9 @@ const Filters = ({ setPrevSyncedKey(initialActiveFiltersKey); skipNextOnFilterRef.current = true; setActiveFilters(initialActiveFilters ?? defaultActiveFilters); + // Drop any chip-edit state from before the resync so an external sync (back/forward, + // sibling URL writer) doesn't leave a stale empty chip mounted. + setOpenChipFilterValue(null); } useEffect(() => { @@ -145,121 +155,126 @@ const Filters = ({ return Array.from(map.values()); }, [filterItems]); - const activeDropdownFilterItems = useMemo(() => { - const items: ActiveDropdownFilterItem[] = []; + const activeDropdownFilterGroups = useMemo(() => { + const groups: ActiveDropdownFilterGroup[] = []; dedupedDropdownSources.forEach((filter) => { const selectedIds = activeFilters.dropdownFilters[filter.value] || []; - if (selectedIds.length > 0) { - filter.options.forEach((option) => { - if (selectedIds.includes(option.id)) { - items.push({ - ...option, - filterValue: filter.value, - }); - } - }); - } + if (selectedIds.length === 0 && openChipFilterValue !== filter.value) return; + groups.push({ + filterValue: filter.value, + title: filter.title, + pluralTitle: filter.pluralTitle, + options: filter.options, + selectedIds, + }); }); - return items; - }, [activeFilters.dropdownFilters, dedupedDropdownSources]); + return groups; + }, [activeFilters.dropdownFilters, dedupedDropdownSources, openChipFilterValue]); - const handleRemoveDropdownFilter = useCallback( - (optionId: string, filterValue: string) => { - const currentSelection = activeFilters.dropdownFilters[filterValue] || []; - const newSelection = currentSelection.filter((id) => id !== optionId); - setDropdownSelection(filterValue, newSelection); - }, - [activeFilters.dropdownFilters, setDropdownSelection], + const leadingFilters = useMemo( + () => filterItems.filter((filter) => filter.type !== "nestedFilterDropdown"), + [filterItems], + ); + const nestedFilters = useMemo( + () => + filterItems.filter( + (filter): filter is Extract => + filter.type === "nestedFilterDropdown", + ), + [filterItems], ); return ( -
- {/* Filter buttons row */} -
- {filterItems.map((filter) => { - if (filter.type === "button") { - return ( - + {leadingFilters.map((filter) => { + if (filter.type === "button") { + return ( + + ); + } + + if (filter.type === "dropdown") { + const selectedOptions = activeFilters.dropdownFilters[filter.value]; + return ( +
+ setDropdownSelection(filter.value, items)} + withButtons={isServerSide} /> - ); - } +
+ ); + } - if (filter.type === "dropdown") { - const selectedOptions = activeFilters.dropdownFilters[filter.value]; - return ( -
- setDropdownSelection(filter.value, items)} - withButtons={isServerSide} - /> -
- ); - } + return null; + })} - if (filter.type === "nestedFilterDropdown") { - const categories: FilterCategory[] = filter.children.map((child) => ({ - key: child.value, - label: child.title, - options: child.options, - selectedValues: activeFilters.dropdownFilters[child.value] ?? [], - })); - return ( - - setActiveFilters((prev) => { - const next = { ...prev.dropdownFilters }; - filter.children.forEach((child) => { - delete next[child.value]; - }); - return { ...prev, dropdownFilters: next }; - }) - } - /> - ); + {activeDropdownFilterGroups.map((group) => ( + setDropdownSelection(group.filterValue, ids)} + onClear={() => { + setDropdownSelection(group.filterValue, []); + setOpenChipFilterValue((prev) => (prev === group.filterValue ? null : prev)); + }} + onOpenChange={(open) => + setOpenChipFilterValue((prev) => { + if (open) return group.filterValue; + return prev === group.filterValue ? null : prev; + }) } + /> + ))} - return null; - })} - {headerControls ? ( -
- {headerControls} -
- ) : null} -
+ {nestedFilters.map((filter) => { + const categories: FilterCategory[] = filter.children.map((child) => ({ + key: child.value, + label: child.title, + options: child.options, + selectedValues: activeFilters.dropdownFilters[child.value] ?? [], + })); + return ( + + setActiveFilters((prev) => { + const next = { ...prev.dropdownFilters }; + filter.children.forEach((child) => { + delete next[child.value]; + }); + return { ...prev, dropdownFilters: next }; + }) + } + /> + ); + })} - {/* Active dropdown filters row */} - {activeDropdownFilterItems.length > 0 ? ( -
- {activeDropdownFilterItems.map((item) => ( - - ))} + {headerControls ? ( +
+ {headerControls}
) : null}
diff --git a/client/src/shared/components/List/Filters/NestedDropdownFilter.test.tsx b/client/src/shared/components/List/Filters/NestedDropdownFilter.test.tsx index 5cb1f819e..a249da852 100644 --- a/client/src/shared/components/List/Filters/NestedDropdownFilter.test.tsx +++ b/client/src/shared/components/List/Filters/NestedDropdownFilter.test.tsx @@ -1,8 +1,36 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import NestedDropdownFilter, { type FilterCategory } from "./NestedDropdownFilter"; import { computeNestedPosition } from "./useFilterDropdownPosition"; +const mockedDimensions = { + width: 1280, + height: 800, + isPhone: false, + isTablet: false, + isLaptop: false, + isDesktop: true, +}; + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: () => mockedDimensions, +})); + +const setViewport = (overrides: Partial) => { + Object.assign(mockedDimensions, overrides); +}; + +const resetViewport = () => { + Object.assign(mockedDimensions, { + width: 1280, + height: 800, + isPhone: false, + isTablet: false, + isLaptop: false, + isDesktop: true, + }); +}; + const rect = (overrides: Partial): DOMRect => { const base = { x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 }; const merged = { ...base, ...overrides }; @@ -178,6 +206,69 @@ describe("NestedDropdownFilter", () => { }); }); +describe("NestedDropdownFilter on small viewports", () => { + afterEach(() => { + resetViewport(); + }); + + it("replaces the category list with the selected category's options on click in mobile mode", async () => { + setViewport({ isPhone: true, isDesktop: false, width: 375 }); + const onChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("nested-dropdown-filter")); + fireEvent.click(screen.getByTestId("nested-dropdown-filter-row-firmware")); + + await waitFor(() => { + expect(screen.getByTestId("filter-option-v3.5.1")).toBeInTheDocument(); + }); + + // Sibling category rows are no longer rendered — the options replaced them. + expect(screen.queryByTestId("nested-dropdown-filter-row-status")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("filter-option-v3.5.1")); + expect(onChange).toHaveBeenCalledWith("firmware", ["v3.5.1"]); + }); + + it("returns to the category list when the back affordance is clicked", async () => { + setViewport({ isPhone: true, isDesktop: false, width: 375 }); + + render( + , + ); + + fireEvent.click(screen.getByTestId("nested-dropdown-filter")); + fireEvent.click(screen.getByTestId("nested-dropdown-filter-row-firmware")); + + await waitFor(() => { + expect(screen.getByTestId("filter-option-v3.5.1")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("nested-dropdown-filter-back")); + + expect(screen.getByTestId("nested-dropdown-filter-row-status")).toBeInTheDocument(); + expect(screen.getByTestId("nested-dropdown-filter-row-firmware")).toBeInTheDocument(); + expect(screen.queryByTestId("filter-option-v3.5.1")).not.toBeInTheDocument(); + }); + + it("does not portal a side-by-side submenu when in mobile mode", () => { + setViewport({ isTablet: true, isDesktop: false, width: 800 }); + + render( + , + ); + + fireEvent.click(screen.getByTestId("nested-dropdown-filter")); + fireEvent.click(screen.getByTestId("nested-dropdown-filter-row-firmware")); + + // The portaled side panel testId is reserved for the desktop hover layout. + expect(screen.queryByTestId("nested-dropdown-filter-submenu-firmware")).not.toBeInTheDocument(); + }); +}); + describe("computeNestedPosition", () => { // Outer popover sits in the upper-left area of a roomy viewport so the row // sits near the bottom of the parent surface. diff --git a/client/src/shared/components/List/Filters/NestedDropdownFilter.tsx b/client/src/shared/components/List/Filters/NestedDropdownFilter.tsx index 8bfae95ac..3e1cf7d73 100644 --- a/client/src/shared/components/List/Filters/NestedDropdownFilter.tsx +++ b/client/src/shared/components/List/Filters/NestedDropdownFilter.tsx @@ -1,8 +1,8 @@ -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { type DropdownOption } from "./DropdownFilter"; -import NestedSubmenu from "./NestedSubmenu"; +import NestedSubmenu, { CheckboxOptionRow } from "./NestedSubmenu"; import { POPOVER_VIEWPORT_PADDING, useFilterDropdownPosition } from "./useFilterDropdownPosition"; import { useNestedDropdownHoverState } from "./useNestedDropdownHoverState"; import { ChevronDown } from "@/shared/assets/icons"; @@ -25,12 +25,56 @@ export type FilterCategory = { }; type NestedDropdownFilterProps = { - /** Trigger button label (e.g. "Filters", "More"). */ + /** Trigger button label (e.g. "Filters", "More", "Add Filter"). */ label: string; categories: FilterCategory[]; onChange: (key: string, selectedValues: string[]) => void; onClearAll: () => void; testId?: string; + /** Optional icon rendered before the label. Suppresses the chevron suffix when set. */ + prefixIcon?: ReactNode; +}; + +type CategoryRowButtonProps = { + category: FilterCategory; + onClick: () => void; + isActive?: boolean; +}; + +const CategoryRowButton = ({ category, onClick, isActive = false }: CategoryRowButtonProps) => { + const isEmpty = category.options.length === 0; + const selectedCount = category.selectedValues.length; + return ( + + ); }; type CategoryRowProps = { @@ -55,9 +99,7 @@ const CategoryRow = ({ onNestedLeave, }: CategoryRowProps) => { const triggerRef = useRef(null); - const isEmpty = category.options.length === 0; - const selectedCount = category.selectedValues.length; const showNested = isActive && !isEmpty; const { position, nestedRef } = useFilterDropdownPosition({ @@ -85,37 +127,13 @@ const CategoryRow = ({ }} onMouseLeave={onRowLeave} > - + /> {showNested ? ( void; +}; + +const MobileCategoryList = ({ categories, onSelect }: MobileCategoryListProps) => ( + <> + {categories.map((category, index) => ( +
+ { + if (category.options.length > 0) onSelect(category.key); + }} + /> + {index < categories.length - 1 ? : null} +
+ ))} + +); + +type MobileOptionListProps = { + category: FilterCategory; + onBack: () => void; + onToggleOption: (categoryKey: string, optionId: string) => void; +}; + +const MobileOptionList = ({ category, onBack, onToggleOption }: MobileOptionListProps) => ( + <> + + + {category.options.map((option, index) => ( +
+ onToggleOption(category.key, id)} + /> + {index < category.options.length - 1 ? : null} +
+ ))} + +); + const NestedDropdownFilterContent = ({ label, categories, onChange, onClearAll, testId, + prefixIcon, }: NestedDropdownFilterProps) => { const [showPopover, setShowPopover] = useState(false); const { triggerRef } = usePopover(); const parentPopoverRef = useRef(null); - const { height: windowHeight } = useWindowDimensions(); + const { height: windowHeight, isPhone, isTablet } = useWindowDimensions(); + // Phone/tablet lack horizontal room for parent + side panel; the nested layout + // collapses into a drilldown that swaps the parent content instead. + const isMobile = isPhone || isTablet; const [popoverPosition, setPopoverPosition] = useState(positions["bottom right"]); const [optionsMaxHeight, setOptionsMaxHeight] = useState(); + const [mobileSelectedKey, setMobileSelectedKey] = useState(null); - const closeOuterPopover = useCallback(() => setShowPopover(false), []); + const closeOuterPopover = useCallback(() => { + setShowPopover(false); + setMobileSelectedKey(null); + }, []); const { activeRowKey, handleRowEnter, scheduleClose, cancelClose, closeAll } = useNestedDropdownHoverState(closeOuterPopover); + const handleMobileToggleOption = useCallback( + (categoryKey: string, optionId: string) => { + const category = categories.find((c) => c.key === categoryKey); + if (!category) return; + const next = category.selectedValues.includes(optionId) + ? category.selectedValues.filter((id) => id !== optionId) + : [...category.selectedValues, optionId]; + onChange(categoryKey, next); + }, + [categories, onChange], + ); + + const mobileSelectedCategory = useMemo( + () => (isMobile ? (categories.find((c) => c.key === mobileSelectedKey) ?? null) : null), + [isMobile, categories, mobileSelectedKey], + ); + useEffect(() => { if (!showPopover || !triggerRef.current) { return; @@ -191,16 +290,23 @@ const NestedDropdownFilterContent = ({ size={sizes.compact} textColor="text-text-primary" className="overflow-hidden !px-3" - onClick={() => setShowPopover((prev) => !prev)} + onClick={() => { + // Reset drilldown so reopening the popover starts at the category list. + setMobileSelectedKey(null); + setShowPopover((prev) => !prev); + }} testId={testId ?? "nested-dropdown-filter"} + prefixIcon={prefixIcon} suffixIcon={ -
- -
+ prefixIcon ? null : ( +
+ +
+ ) } > {label} @@ -211,6 +317,7 @@ const NestedDropdownFilterContent = ({ testId="nested-dropdown-filter-popover" position={popoverPosition} offset={8} + freezePosition buttons={ activeCount > 0 ? [ @@ -228,10 +335,8 @@ const NestedDropdownFilterContent = ({ >
{ - // The outer popover surface (with padding/shadow) is the `.popover-content` ancestor. - // Anchor the side-rendered nested panel to its right edge, not the inner scroll area. - // React 19 cycles ref callbacks (node → null → node) on each render — only update - // on non-null nodes so transient nulls don't leave the ref stale during a re-render. + // React 19 cycles ref callbacks (node → null → node) on each render — only update on + // non-null nodes so transient nulls don't leave the parent surface ref stale. if (node) { parentPopoverRef.current = (node.closest(".popover-content") as HTMLDivElement) ?? null; } @@ -239,21 +344,31 @@ const NestedDropdownFilterContent = ({ className="space-y-0 overflow-y-auto overscroll-contain" style={{ maxHeight: optionsMaxHeight }} > - {categories.map((category, index) => ( -
- - {index < categories.length - 1 ? : null} -
- ))} + {isMobile && mobileSelectedCategory ? ( + setMobileSelectedKey(null)} + onToggleOption={handleMobileToggleOption} + /> + ) : isMobile ? ( + + ) : ( + categories.map((category, index) => ( +
+ + {index < categories.length - 1 ? : null} +
+ )) + )}
) : null} diff --git a/client/src/shared/components/List/Filters/NestedSubmenu.tsx b/client/src/shared/components/List/Filters/NestedSubmenu.tsx index df9679937..6c7f11979 100644 --- a/client/src/shared/components/List/Filters/NestedSubmenu.tsx +++ b/client/src/shared/components/List/Filters/NestedSubmenu.tsx @@ -11,6 +11,29 @@ import Divider from "@/shared/components/Divider"; // outer is height-clipped so the inner area doesn't overflow the panel chrome. const PANEL_PADDING_TOTAL = 48; +type CheckboxOptionRowProps = { + option: DropdownOption; + checked: boolean; + onToggle: (id: string) => void; +}; + +export const CheckboxOptionRow = ({ option, checked, onToggle }: CheckboxOptionRowProps) => ( +
onToggle(option.id)} + data-testid={`filter-option-${option.id}`} + > +
+ {option.label} +
+ +
+); + type NestedSubmenuProps = { /** Used in test ids and as a stable key. */ categoryKey: string; @@ -60,22 +83,9 @@ const NestedSubmenu = ({ position?.maxHeight !== undefined ? { maxHeight: `${position.maxHeight - PANEL_PADDING_TOTAL}px` } : undefined } > - {options.map((item, index) => ( -
-
onToggleItem(item.id)} - data-testid={`filter-option-${item.id}`} - > -
- {item.label} -
- -
+ {options.map((option, index) => ( +
+ {index < options.length - 1 ? : null}
))} diff --git a/client/src/shared/components/List/Filters/types.ts b/client/src/shared/components/List/Filters/types.ts index 828dfc2b5..dd5a884c8 100644 --- a/client/src/shared/components/List/Filters/types.ts +++ b/client/src/shared/components/List/Filters/types.ts @@ -1,3 +1,5 @@ +import { type ReactNode } from "react"; + import { StatusCircleStatus } from "@/shared/components/StatusCircle/constants"; export type DropdownOption = { @@ -24,6 +26,10 @@ export type DropdownFilterItem = BaseFilterItem & { options: DropdownOption[]; defaultOptionIds: string[]; showSelectAll?: boolean; + // Plural form of `title` used in active-filter chips when multiple options are selected + // (e.g. "3 statuses"). Defaults to `title + "s"` if omitted, which is wrong for + // irregular plurals like "Status". + pluralTitle?: string; }; /** @@ -34,6 +40,10 @@ export type DropdownFilterItem = BaseFilterItem & { export type NestedFilterDropdownItem = BaseFilterItem & { type: "nestedFilterDropdown"; children: DropdownFilterItem[]; + // Optional icon rendered to the left of the trigger label. When provided the + // trigger drops its chevron suffix so the icon-led action style ("+ Add Filter") + // reads as a button instead of a select. + prefixIcon?: ReactNode; }; export type FilterItem = ButtonFilterItem | DropdownFilterItem | NestedFilterDropdownItem; diff --git a/client/src/shared/components/List/List.test.tsx b/client/src/shared/components/List/List.test.tsx index 2e7a79b5c..d43bb461d 100644 --- a/client/src/shared/components/List/List.test.tsx +++ b/client/src/shared/components/List/List.test.tsx @@ -473,7 +473,7 @@ describe("List", () => { const { rerender } = render( {...props} />); - expect(await screen.findByTestId("active-filter-valueRange-low")).toBeInTheDocument(); + expect(await screen.findByTestId("active-filter-valueRange")).toBeInTheDocument(); rerender( @@ -485,7 +485,7 @@ describe("List", () => { />, ); - expect(screen.queryByTestId("active-filter-valueRange-low")).not.toBeInTheDocument(); + expect(screen.queryByTestId("active-filter-valueRange")).not.toBeInTheDocument(); }); it("clearSelection callback deselects all items", async () => { diff --git a/client/src/shared/components/Popover/Popover.tsx b/client/src/shared/components/Popover/Popover.tsx index be4941f66..467e609a5 100644 --- a/client/src/shared/components/Popover/Popover.tsx +++ b/client/src/shared/components/Popover/Popover.tsx @@ -16,6 +16,12 @@ type PopoverProps = PopoverContentProps & { offset?: number; xOffset?: number; yOffset?: number; + /** + * Anchor the popover to the trigger's position at the moment of mount and stop + * tracking it afterwards. Renders portal-fixed so layout shifts (e.g. chips inserted + * before the trigger button) don't drag the popover sideways while it's open. + */ + freezePosition?: boolean; }; /** @@ -61,8 +67,12 @@ const Popover = ({ titleSize = "text-heading-200", closePopover, closeIgnoreSelectors = [], + freezePosition = false, }: PopoverProps) => { - const { triggerRef, renderMode } = usePopover(); + const { triggerRef, renderMode: contextRenderMode } = usePopover(); + // Frozen popovers must be portal'd to body so they're positioned relative to the + // viewport rather than the trigger element's absolute container (which moves with it). + const renderMode = freezePosition ? "portal-fixed" : contextRenderMode; const { popoverAnimation, popoverStyle, popoverRef } = usePopoverPosition( triggerRef, offset ?? minimalMargin, @@ -70,6 +80,7 @@ const Popover = ({ yOffset, renderMode, position, + freezePosition, ); const popoverElement = ( diff --git a/client/src/shared/components/Popover/usePopoverPosition.ts b/client/src/shared/components/Popover/usePopoverPosition.ts index 4beabcdc4..7c86d6ef5 100644 --- a/client/src/shared/components/Popover/usePopoverPosition.ts +++ b/client/src/shared/components/Popover/usePopoverPosition.ts @@ -1,4 +1,4 @@ -import { CSSProperties, MutableRefObject, useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { CSSProperties, MutableRefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { minimalMargin } from "@/shared/components/Popover/constants"; import { Position, positions } from "@/shared/constants"; import useMeasure, { UseMeasureRect } from "@/shared/hooks/useMeasure"; @@ -92,6 +92,7 @@ const usePopoverPosition = ( yOffset: number, renderMode: PopoverRenderMode, position?: Position, + freezePosition?: boolean, ) => { const { width: viewportWidth, height: viewportHeight } = useWindowDimensions(); @@ -106,33 +107,40 @@ const usePopoverPosition = ( // Track actual visible viewport dimensions (changes with zoom) const [visibleViewport, setVisibleViewport] = useState({ width: viewportWidth, height: viewportHeight }); + // Once a freeze-positioned popover takes its first valid measurement we stop tracking + // the trigger's live coordinates so layout shifts (chips appearing before the trigger, + // sibling resizes) don't drag the popover around mid-interaction. We keep updating + // visibleViewport regardless so the layout effect can re-clamp the frozen anchor on + // viewport resize / zoom / mobile-chrome collapse. + const frozenRef = useRef(false); const updateMeasurements = useCallback(() => { - if (triggerRef.current) { - const rect = triggerRef.current.getBoundingClientRect(); - const vv = window.visualViewport; - const currentViewportHeight = vv?.height ?? viewportHeight; - - // Only update if the trigger is visible in the viewport. - // When scrolled out of view, getBoundingClientRect returns off-screen coordinates - // which cause incorrect overflow detection and position flipping. - const isInViewport = rect.bottom > 0 && rect.top < currentViewportHeight; - if (!isInViewport) { - setTriggerRect(null); - setPopoverStyle({ visibility: "hidden" }); - return; - } + if (!triggerRef.current) return; + const vv = window.visualViewport; + const currentViewportHeight = vv?.height ?? viewportHeight; + setVisibleViewport({ + width: vv?.width ?? viewportWidth, + height: currentViewportHeight, + }); - const { x, y, width, height, top, left, bottom, right } = rect; - setTriggerRect({ x, y, width, height, top, left, bottom, right }); - setInitialPageOffset(window.scrollY); + if (freezePosition && frozenRef.current) return; - // Use visualViewport dimensions when available (reflects actual visible area after zoom) - setVisibleViewport({ - width: vv?.width ?? viewportWidth, - height: currentViewportHeight, - }); + const rect = triggerRef.current.getBoundingClientRect(); + // Only update if the trigger is visible in the viewport. + // When scrolled out of view, getBoundingClientRect returns off-screen coordinates + // which cause incorrect overflow detection and position flipping. + const isInViewport = rect.bottom > 0 && rect.top < currentViewportHeight; + if (!isInViewport) { + setTriggerRect(null); + setPopoverStyle({ visibility: "hidden" }); + return; } - }, [triggerRef, viewportWidth, viewportHeight]); + + const { x, y, width, height, top, left, bottom, right } = rect; + setTriggerRect({ x, y, width, height, top, left, bottom, right }); + setInitialPageOffset(window.scrollY); + + if (freezePosition) frozenRef.current = true; + }, [triggerRef, viewportWidth, viewportHeight, freezePosition]); useEffect(() => { updateMeasurements();