From 684c3173f43920f9c6052d2bb1c25733814afdc2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 11:24:33 +0000 Subject: [PATCH] feat(filters): filter by either end of a route, and by several stations per end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route body paired two single Selects behind an Apply gated on `origin && destination`, so the only question it could ask was "A to B". "Everything leaving Nagad" and "everything arriving at Gelan" are both real operator questions, and neither was expressible. Origin and destination are now independent multi-selects, either of which may be left empty: OR inside a side, AND across the two. A hint line says so in words, because two stacked pickers do not communicate that on their own, and a swap button flips the ends for a return leg. The value is a tagged flat list — ["o:", "d:", ...] — because url.ts knows exactly one encoding, comma-split inside one query param; the tags are what buy back the two sides. decodeRouteValue still reads the old untagged pair, so existing deep links and saved views keep working. routeParams(originKey, destinationKey) maps each side onto its own API param and omits an empty side entirely rather than sending a blank one. Each side's dropdown stays shut until something is typed. That needs two levers, not one: openOnFocus={false} covers the focus, but MultiSelect's PillsInput root also calls openDropdown() on every click with no prop to gate it, so dropdownOpened is driven off the search text instead. --- .../src/components/filters/FilterPill.tsx | 5 +- .../components/filters/bodies/RouteBody.tsx | 144 +++++++++++++----- .../src/components/filters/format.ts | 14 +- .../src/components/filters/index.ts | 1 + .../src/components/filters/route.test.ts | 61 ++++++++ .../src/components/filters/route.ts | 67 ++++++++ .../src/components/filters/types.ts | 10 +- .../pages/bookings/BookingRequestsPage.tsx | 4 +- .../pages/contracts/ContractRequestsPage.tsx | 4 +- 9 files changed, 259 insertions(+), 51 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/route.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/route.ts diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx index af97d2fcf..5f1a2b86f 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -21,8 +21,9 @@ const BODIES: Record> = { }; // Most bodies fit a narrow popover; a date range needs room for the presets -// sidebar next to the calendar, so it gets a wider minimum. -const DROPDOWN_WIDTH: Partial> = { date: 340 }; +// sidebar next to the calendar, and a route needs room for two multi-selects' +// worth of yard chips, so both get a wider minimum. +const DROPDOWN_WIDTH: Partial> = { date: 340, route: 320 }; export interface FilterPillProps { def: FilterDef; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx index 55097b05b..ff4e2e5ac 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx @@ -1,59 +1,127 @@ import { useState } from "react"; -import { Button, Select, Stack } from "@mantine/core"; -import { ArrowRight } from "lucide-react"; +import { ActionIcon, Button, Group, MultiSelect, Stack, Text, Tooltip } from "@mantine/core"; +import { ArrowDown, ArrowUpDown } from "lucide-react"; import type { RouteFilterDef } from "../types"; +import { decodeRouteValue, encodeRouteValue, type RouteSelection } from "../route"; import type { FilterBodyProps } from "./TextBody"; +type Side = keyof RouteSelection; + /** - * Origin + destination picked together, each a searchable `Select` over the - * page's yard list — typing filters by yard name, same as any Mantine - * Select. No `OperatorSelect`: a route pair has exactly one operator ("is"), - * which is why DEFAULT_OP.route is the only entry the generic bar needs. + * Origin and destination as two independent multi-selects, either of which may + * be left empty. That is the whole point: "everything leaving Nagad" and + * "everything arriving at Gelan" are real questions an operator asks, and the + * previous body — two single Selects behind an Apply gated on + * `origin && destination` — could only ask the third one. + * + * Semantics are OR inside a side, AND across the two, which the hint line + * below spells out in words rather than making the user infer it from a + * checkbox list. + * + * No `OperatorSelect`: a route still has exactly one operator ("is"), which is + * why DEFAULT_OP.route is the only entry the generic bar needs. */ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps) { - const [origin, setOrigin] = useState(value?.v[0] ?? null); - const [destination, setDestination] = useState(value?.v[1] ?? null); + const [sel, setSel] = useState(() => decodeRouteValue(value?.v ?? [])); + const [search, setSearch] = useState>({ origins: "", destinations: "" }); + + const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id; + const list = (ids: string[]) => ids.map(label).join(" or "); + + // Spelled out, because "OR within a side, AND across sides" is not something + // two stacked pickers communicate on their own. + const hint = !sel.origins.length && !sel.destinations.length + ? "Type to search stations. Pick an origin, a destination, or both." + : !sel.destinations.length + ? `Everything leaving ${list(sel.origins)}.` + : !sel.origins.length + ? `Everything arriving at ${list(sel.destinations)}.` + : `From ${list(sel.origins)} to ${list(sel.destinations)}.`; const apply = () => { - onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined); + onChange(encodeRouteValue(sel)); onClose(); }; - // This popover already lives inside FilterPill's own Popover. A Select's - // dropdown portals separately by default, so a click on an option registers - // as "outside" the outer Popover and closes the whole filter before a pick + // This popover already lives inside FilterPill's own Popover. A dropdown + // portals separately by default, so a click on an option registers as + // "outside" the outer Popover and closes the whole filter before a pick // lands — same nested-portal bug DateBody had. Un-portalling keeps it // inside the outer popover's DOM subtree instead. const comboboxProps = { withinPortal: false } as const; + /** + * The list stays shut until there is something typed. Two open triggers have + * to be neutralised for that, not one: `openOnFocus={false}` handles the + * focus, but MultiSelect's PillsInput root ALSO calls `openDropdown()` on + * every click, ungated — so the only reliable lever is driving + * `dropdownOpened` ourselves off the search text. + * + * Consequence worth knowing: Mantine clears the search on each pick + * (`clearSearchOnChange`, default true), so the list closes after one is + * chosen and typing reopens it. That is the intended resting state — the + * popover opens showing what is already selected, not a wall of stations. + */ + const sideProps = (side: Side) => ({ + data: def.options, + placeholder: sel[side].length ? "Add another" : "Any", + value: sel[side], + onChange: (next: string[]) => setSel((s) => ({ ...s, [side]: next })), + searchValue: search[side], + onSearchChange: (q: string) => setSearch((s) => ({ ...s, [side]: q })), + dropdownOpened: search[side].trim().length > 0, + openOnFocus: false, + comboboxProps, + searchable: true, + clearable: true, + hidePickedOptions: true, + maxDropdownHeight: 200, + nothingFoundMessage: "No station matches", + }); + return ( - - - + + + + + + + setSel((s) => ({ origins: s.destinations, destinations: s.origins }))} + > + + + + + + + + + {hint} + + + + + {/* Enabled even when empty: applying nothing removes the filter, which + is how every other body's Apply behaves. */} + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/format.ts b/apps/edr-freight-web/backoffice/src/components/filters/format.ts index ebb9e167a..af3908be0 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/format.ts @@ -1,5 +1,6 @@ import { formatDate } from "@/lib/format"; import { parseFilters } from "./url"; +import { decodeRouteValue } from "./route"; import { OPERATOR_LABELS, type FilterDef, type FilterValue } from "./types"; /** Human-readable text for one filter's current value — same text a @@ -22,9 +23,18 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string { if (value.op === "between" && days.length === 2) return `${days[0]} → ${days[1]}`; return `${OPERATOR_LABELS[value.op]} ${days[0]}`; } - if (def.type === "route" && value.v.length === 2) { + if (def.type === "route") { const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id; - return `${label(value.v[0])} → ${label(value.v[1])}`; + // "Any" reads as an unconstrained end; a long side collapses to "first +N" + // so the pill can't grow past the rest of the bar. + const side = (ids: string[]) => + ids.length === 0 + ? "Any" + : ids.length <= 2 + ? ids.map(label).join(", ") + : `${label(ids[0])} +${ids.length - 1}`; + const { origins, destinations } = decodeRouteValue(value.v); + return `${side(origins)} → ${side(destinations)}`; } return value.v.join(", "); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/index.ts b/apps/edr-freight-web/backoffice/src/components/filters/index.ts index ea2c28fb2..8d63e6200 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/index.ts @@ -1,6 +1,7 @@ export * from "./types"; export * from "./url"; export * from "./dates"; +export * from "./route"; export * from "./format"; export * from "./clientFilter"; export * from "./ruleEngineFooterProps"; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/route.test.ts b/apps/edr-freight-web/backoffice/src/components/filters/route.test.ts new file mode 100644 index 000000000..9b5c2675f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/route.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { FilterDef } from "./types"; +import { decodeRouteValue, encodeRouteValue, routeParams } from "./route"; +import { decodeFilterValue, encodeFilterValue, toApiParams } from "./url"; +import { formatFilterValue } from "./format"; + +const NAGAD = "11111111-1111-1111-1111-111111111111"; +const DMP = "22222222-2222-2222-2222-222222222222"; +const GELAN = "33333333-3333-3333-3333-333333333333"; + +const ROUTE: FilterDef = { + key: "route", + label: "Route", + type: "route", + options: [ + { value: NAGAD, label: "Nagad" }, + { value: DMP, label: "DMP" }, + { value: GELAN, label: "Gelan" }, + ], + toParams: routeParams("originYardId", "destinationYardId"), +}; + +describe("route filter value", () => { + it("round-trips each side independently, through the URL codec", () => { + const cases = [ + { origins: [NAGAD], destinations: [] }, + { origins: [], destinations: [GELAN] }, + { origins: [NAGAD, DMP], destinations: [GELAN] }, + ]; + for (const sel of cases) { + const value = encodeRouteValue(sel)!; + const raw = encodeFilterValue("route", value); + expect(decodeRouteValue(decodeFilterValue("route", raw)!.v)).toEqual(sel); + } + }); + + it("is no filter at all when both sides are empty", () => { + expect(encodeRouteValue({ origins: [], destinations: [] })).toBeUndefined(); + }); + + it("omits an unconstrained side rather than sending an empty param", () => { + const value = encodeRouteValue({ origins: [NAGAD, DMP], destinations: [] })!; + expect(toApiParams([ROUTE], { route: value })).toEqual({ + originYardId: `${NAGAD},${DMP}`, + destinationYardId: undefined, + }); + }); + + it("still reads the legacy untagged `route=,` pair", () => { + expect(decodeRouteValue([NAGAD, GELAN])).toEqual({ + origins: [NAGAD], + destinations: [GELAN], + }); + }); + + it("labels an empty side 'Any' in the pill", () => { + const value = encodeRouteValue({ origins: [], destinations: [GELAN] })!; + expect(formatFilterValue(ROUTE, value)).toBe("Any → Gelan"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/filters/route.ts b/apps/edr-freight-web/backoffice/src/components/filters/route.ts new file mode 100644 index 000000000..4a0fab05f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/route.ts @@ -0,0 +1,67 @@ +import type { FilterValue } from "./types"; + +const ORIGIN = "o:"; +const DEST = "d:"; + +export interface RouteSelection { + origins: string[]; + destinations: string[]; +} + +/** + * A route filter's `v` is one flat, TAGGED list — `["o:", "d:", …]`. + * + * It has to be flat because `url.ts` knows exactly one encoding for a filter + * value: comma-split inside a single query param. The tags are what buy back + * the two sides, and with them the three things the old fixed + * `[origin, destination]` pair could not express: + * + * - origin only — "everything leaving Nagad" + * - destination only — "everything arriving at Gelan" + * - several yards per side — "leaving Nagad OR DMP, arriving Gelan OR Indode" + * + * Semantics: OR within a side, AND across the two. An empty side is not a + * filter at all (see {@link routeParams}), never "matches nothing". + */ +export function decodeRouteValue(v: string[]): RouteSelection { + const origins: string[] = []; + const destinations: string[] = []; + for (const entry of v) { + if (entry.startsWith(ORIGIN)) origins.push(entry.slice(ORIGIN.length)); + else if (entry.startsWith(DEST)) destinations.push(entry.slice(DEST.length)); + } + // Legacy `?route=,`: deep links and saved views + // written before the tags existed. Untagged, and always exactly the pair. + if (!origins.length && !destinations.length && v.length === 2) { + return { origins: [v[0]], destinations: [v[1]] }; + } + return { origins, destinations }; +} + +/** Inverse of {@link decodeRouteValue}. `undefined` when both sides are empty — that is "no filter". */ +export function encodeRouteValue(sel: RouteSelection): FilterValue | undefined { + const v = [ + ...sel.origins.map((id) => `${ORIGIN}${id}`), + ...sel.destinations.map((id) => `${DEST}${id}`), + ]; + return v.length ? { op: "is", v } : undefined; +} + +/** + * `toParams` for a route filter — each side onto its own comma-separated API + * param, mirroring `dateRangeParams`. An empty side maps to `undefined` so + * `cleanParams` drops the param entirely; sending `originYardId=` instead + * would have the server filter on an empty list. + */ +export function routeParams( + originKey: string, + destinationKey: string, +): (v: FilterValue) => Record { + return (value) => { + const { origins, destinations } = decodeRouteValue(value.v); + return { + [originKey]: origins.join(",") || undefined, + [destinationKey]: destinations.join(",") || undefined, + }; + }; +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/types.ts b/apps/edr-freight-web/backoffice/src/components/filters/types.ts index 9f0c35d43..c3cb5cdb9 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/types.ts @@ -89,11 +89,11 @@ export interface BooleanFilterDef extends FilterDefBase { } /** - * Origin + destination picked together as one pill — `v` is always the - * 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's - * Apply button stays disabled until both sides are chosen, same rule - * `DateBody` uses for a `between` range). One shared `options` list drives - * both selects. + * Origin + destination as one pill, each side holding any number of yards and + * either side allowed to be empty. `v` is the tagged flat list described in + * `route.ts` — use `decodeRouteValue` / `encodeRouteValue` to read or write it, + * and `routeParams(originKey, destinationKey)` as the def's `toParams`. One + * shared `options` list drives both sides. */ export interface RouteFilterDef extends FilterDefBase { type: "route"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index b7f64d213..dcb561570 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -28,7 +28,7 @@ import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { ExportButton } from "@/components/export/ExportButton"; import { formatDate, humanize } from "@/lib/format"; -import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters"; +import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. @@ -170,7 +170,7 @@ export default function BookingRequestsPage() { { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true }, { key: "route", label: "Route", type: "route", options: yardOptions, - toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + toParams: routeParams("originYardId", "destinationYardId"), }, { key: "created", label: "Created", type: "date", secondary: true, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index 4f761c6f0..455a6473e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -52,7 +52,7 @@ import { DataTableFooter, type ColumnDef, } from "@edr/ui-common"; -import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters"; +import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters"; import { ExportButton } from "@/components/export/ExportButton"; /** Every filterable status — the pill tabs are gone, so the select carries them all. */ @@ -184,7 +184,7 @@ export default function ContractRequestsPage() { label: "Route", type: "route", options: yardOptions, - toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + toParams: routeParams("originYardId", "destinationYardId"), }, ], [filterOptions, yardOptions, serviceTypeOptions],