mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
fix(filters): auto-apply radios, route popover bug, date operators, polish
- Radio-based filters (single-select enum, boolean) apply the instant
an option is picked and close — no Apply click needed. Checkbox
(multi-select) keeps the explicit Apply, since picking several is a
multi-step gesture.
- Route filter's Select dropdowns portal separately by default, so a
click landed as "outside" the outer pill popover and closed it
before a pick registered — same class of bug DateBody had.
`comboboxProps={{ withinPortal: false }}` fixes it.
- Route filter now pinned by default on both pages instead of behind
More filters.
- Date filter: widened to before/between/after (repository already
applies each bound independently) via a new `dateRangeParams`
helper — the old positional `{v[0]:from, v[1]:to}` mapping silently
put a "before" pick in the from param. Added a calendar icon + sm
sizing, and a wider popover so the range presets sidebar has room.
- Pill's clear (X) button enlarged; search box border/text opacity
strengthened to match the pill trigger restyle.
This commit is contained in:
@@ -84,7 +84,16 @@ export function FilterBar({
|
||||
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
|
||||
size="xs"
|
||||
radius="lg"
|
||||
styles={{ input: { fontWeight: 400 } }}
|
||||
// Regular weight (not the Button-driven 600 the rest of the bar
|
||||
// uses) and a solid, fully-opaque border/text — same "opaque, not
|
||||
// faint" fix the inactive pill trigger got.
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 400,
|
||||
borderColor: "var(--mantine-color-gray-6)",
|
||||
color: "var(--mantine-color-gray-9)",
|
||||
},
|
||||
}}
|
||||
style={{ minWidth: 160, flex: "1 1 160px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,10 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
||||
route: RouteBody,
|
||||
};
|
||||
|
||||
// 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<Record<FilterDef["type"], number>> = { date: 340 };
|
||||
|
||||
export interface FilterPillProps {
|
||||
def: FilterDef;
|
||||
value: FilterValue | undefined;
|
||||
@@ -57,7 +61,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
|
||||
active ? (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
size={16}
|
||||
size={22}
|
||||
radius="xl"
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
@@ -66,7 +70,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
|
||||
onChange(undefined);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
@@ -77,7 +81,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
|
||||
{active ? `${def.label} | ${formatFilterValue(def, value!)}` : def.label}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown miw={260} p="xs">
|
||||
<Popover.Dropdown miw={DROPDOWN_WIDTH[def.type] ?? 260} p="xs">
|
||||
<Body def={def} value={value} onChange={onChange} onClose={() => setOpened(false)} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Radio, Stack } from "@mantine/core";
|
||||
import { Radio, Stack } from "@mantine/core";
|
||||
|
||||
import { DEFAULT_OP } from "../types";
|
||||
import type { BooleanFilterDef, Operator } from "../types";
|
||||
@@ -10,23 +10,23 @@ export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps<B
|
||||
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.boolean);
|
||||
const [v, setV] = useState(value?.v[0] ?? "");
|
||||
|
||||
const apply = () => {
|
||||
onChange(v ? { op, v: [v] } : undefined);
|
||||
// Two mutually-exclusive options — apply the moment one is picked, same as
|
||||
// EnumBody's single-select radio. No Apply button needed.
|
||||
const pick = (next: string) => {
|
||||
setV(next);
|
||||
onChange({ op, v: [next] });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||
<Radio.Group value={v} onChange={setV}>
|
||||
<Radio.Group value={v} onChange={pick}>
|
||||
<Stack gap={6}>
|
||||
<Radio value="true" label={def.trueLabel ?? "Yes"} size="sm" />
|
||||
<Radio value="false" label={def.falseLabel ?? "No"} size="sm" />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
<Button size="sm" onClick={apply}>
|
||||
Apply
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
|
||||
@@ -58,6 +59,8 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<Date
|
||||
{op === "between" ? (
|
||||
<DatePickerInput
|
||||
type="range"
|
||||
size="sm"
|
||||
leftSection={<CalendarDays size={14} />}
|
||||
placeholder="Any"
|
||||
value={[from, to]}
|
||||
onChange={([f, t]) => {
|
||||
@@ -71,6 +74,8 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<Date
|
||||
/>
|
||||
) : (
|
||||
<DatePickerInput
|
||||
size="sm"
|
||||
leftSection={<CalendarDays size={14} />}
|
||||
placeholder="Any"
|
||||
value={from}
|
||||
onChange={setFrom}
|
||||
|
||||
@@ -56,11 +56,20 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
|
||||
|
||||
const hiddenCount = def.options.length - visible.length;
|
||||
|
||||
const apply = () => {
|
||||
onChange(selected.length ? { op, v: selected } : undefined);
|
||||
const apply = (v: string[] = selected) => {
|
||||
onChange(v.length ? { op, v } : undefined);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Single-select is a radio pick, not a build-up-a-set gesture — apply the
|
||||
// instant one is chosen, same as picking an option in a plain Select.
|
||||
// Checkbox (multiple) still needs the explicit Apply: picking several
|
||||
// options is a multi-step gesture the popover shouldn't close mid-way through.
|
||||
const applyRadio = (v: string) => {
|
||||
setSelected([v]);
|
||||
apply([v]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<OperatorSelect def={def} value={op} onChange={setOp} />
|
||||
@@ -95,7 +104,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
|
||||
) : (
|
||||
<Radio.Group
|
||||
value={selected[0] ?? ""}
|
||||
onChange={(v) => setSelected(v ? [v] : [])}
|
||||
onChange={(v) => v && applyRadio(v)}
|
||||
aria-label={`Filter by ${def.label}`}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
@@ -120,9 +129,11 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
<Button size="sm" onClick={apply}>
|
||||
Apply
|
||||
</Button>
|
||||
{multiple && (
|
||||
<Button size="sm" onClick={() => apply()}>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<Rou
|
||||
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
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" w={240}>
|
||||
<Select
|
||||
@@ -28,6 +35,7 @@ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<Rou
|
||||
data={def.options}
|
||||
value={origin}
|
||||
onChange={setOrigin}
|
||||
comboboxProps={comboboxProps}
|
||||
searchable
|
||||
clearable
|
||||
autoFocus
|
||||
@@ -39,6 +47,7 @@ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<Rou
|
||||
data={def.options}
|
||||
value={destination}
|
||||
onChange={setDestination}
|
||||
comboboxProps={comboboxProps}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { FilterValue } from "./types";
|
||||
|
||||
/** Local start-of-day -> ISO, for inclusive "from" date filters. Lifted out of
|
||||
* ContractRequestsPage (where it was duplicated into BookingRequestsPage) so
|
||||
* every date filter shares one definition. */
|
||||
@@ -29,3 +31,21 @@ export function parseDateStr(dateStr: string): Date {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `toParams` for a date `FilterDef` widened to `["between", "before", "after"]`
|
||||
* operators. `DateBody` always emits a single-element `v` for before/after —
|
||||
* a plain positional `{[fromKey]: v[0], [toKey]: v[1]}` mapping (the
|
||||
* between-only default) would wrongly land a "before" pick in `fromKey`
|
||||
* instead of `toKey`. This routes each operator to the right bound.
|
||||
*/
|
||||
export function dateRangeParams(
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
): (v: FilterValue) => Record<string, string | undefined> {
|
||||
return (value) => {
|
||||
if (value.op === "before") return { [toKey]: value.v[0] };
|
||||
if (value.op === "after") return { [fromKey]: value.v[0] };
|
||||
return { [fromKey]: value.v[0], [toKey]: value.v[1] };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { formatDate, humanize } from "@/lib/format";
|
||||
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
|
||||
import { FilterBar, dateRangeParams, 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.
|
||||
@@ -150,11 +150,19 @@ 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, secondary: true,
|
||||
key: "route", label: "Route", type: "route", options: yardOptions,
|
||||
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
|
||||
},
|
||||
{ key: "created", label: "Created", type: "date", secondary: true, toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }) },
|
||||
{ key: "scheduled", label: "Scheduled", type: "date", secondary: true, toParams: ({ v }) => ({ scheduledFrom: v[0], scheduledTo: v[1] }) },
|
||||
{
|
||||
key: "created", label: "Created", type: "date", secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||
},
|
||||
{
|
||||
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||
},
|
||||
],
|
||||
[filterOptions, yardOptions],
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
|
||||
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||
|
||||
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
|
||||
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
|
||||
@@ -156,16 +156,17 @@ export default function ContractRequestsPage() {
|
||||
label: "Created",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
// DateBody already converts to start/end-of-day ISO before calling
|
||||
// onChange, so this just routes to the API's existing param names.
|
||||
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
|
||||
// Before/after are safe to expose: the repository applies
|
||||
// createdFrom/createdTo independently, so a single-sided bound
|
||||
// already works server-side.
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
label: "Route",
|
||||
type: "route",
|
||||
options: yardOptions,
|
||||
secondary: true,
|
||||
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user