Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a95a81bb12 | ||
|
|
74cb0c2793 |
5 changed files with 59 additions and 96 deletions
|
|
@ -318,11 +318,13 @@ export function AgentsPage() {
|
||||||
header: "hive",
|
header: "hive",
|
||||||
render: (a) => a.hive ?? "—",
|
render: (a) => a.hive ?? "—",
|
||||||
sortBy: (a) => a.hive ?? "",
|
sortBy: (a) => a.hive ?? "",
|
||||||
// "—" (not "") so the missing-hive option in the filter dropdown
|
// "—" (not "") so the missing-hive option in the filter list reads
|
||||||
// reads the same as the cell itself, rather than showing a blank
|
// the same as the cell itself, rather than showing a blank choice.
|
||||||
// choice.
|
// Multiselect, not single-value `"select"` — an operator narrowing
|
||||||
filterValue: (a) => a.hive ?? "—",
|
// to a handful of hives at once shouldn't need to filter one at a
|
||||||
filterMode: "select",
|
// time.
|
||||||
|
filterValues: (a) => [a.hive ?? "—"],
|
||||||
|
filterMode: "multiselect",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
|
@ -333,8 +335,9 @@ export function AgentsPage() {
|
||||||
// label and blew the row out (mara: "looks messy"). That string
|
// label and blew the row out (mara: "looks messy"). That string
|
||||||
// now lives in its own "message" column below.
|
// now lives in its own "message" column below.
|
||||||
sortBy: (a) => FRESHNESS[a.freshness].label,
|
sortBy: (a) => FRESHNESS[a.freshness].label,
|
||||||
filterValue: (a) => FRESHNESS[a.freshness].label,
|
// Multiselect, same reasoning as the hive column above.
|
||||||
filterMode: "select",
|
filterValues: (a) => [FRESHNESS[a.freshness].label],
|
||||||
|
filterMode: "multiselect",
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const { tone, label } = FRESHNESS[a.freshness];
|
const { tone, label } = FRESHNESS[a.freshness];
|
||||||
return (
|
return (
|
||||||
|
|
@ -379,8 +382,9 @@ export function AgentsPage() {
|
||||||
// real per-badge logic lives there, this column just wires its
|
// real per-badge logic lives there, this column just wires its
|
||||||
// callbacks to the page's own state/handlers.
|
// callbacks to the page's own state/handlers.
|
||||||
sortBy: (a) => a.wanted ?? "",
|
sortBy: (a) => a.wanted ?? "",
|
||||||
filterValue: (a) => a.wanted ?? "no declaration",
|
// Multiselect, same reasoning as the hive column above.
|
||||||
filterMode: "select",
|
filterValues: (a) => [a.wanted ?? "no declaration"],
|
||||||
|
filterMode: "multiselect",
|
||||||
render: (a) => {
|
render: (a) => {
|
||||||
const err = actionErrors.get(a.name);
|
const err = actionErrors.get(a.name);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,11 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortBy: (h) => FRESHNESS[h.freshness].label,
|
sortBy: (h) => FRESHNESS[h.freshness].label,
|
||||||
filterValue: (h) => FRESHNESS[h.freshness].label,
|
// Multiselect (not the single-value `"select"` mode) so the operator
|
||||||
filterMode: "select",
|
// can filter for e.g. "stale or offline" in one pass rather than one
|
||||||
|
// freshness state at a time.
|
||||||
|
filterValues: (h) => [FRESHNESS[h.freshness].label],
|
||||||
|
filterMode: "multiselect",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -312,14 +312,12 @@ export function IssueReportPage() {
|
||||||
),
|
),
|
||||||
ariaSort: ariaSortFor("assignees"),
|
ariaSort: ariaSortFor("assignees"),
|
||||||
render: (r) => (r.assignees.length ? r.assignees.join(", ") : "—"),
|
render: (r) => (r.assignees.length ? r.assignees.join(", ") : "—"),
|
||||||
// Text, not `"select"` — a row can carry more than one assignee,
|
// Multiselect, same machinery the "labels" column above already
|
||||||
// and `Table`'s select mode matches one whole string per row
|
// uses — `r.assignees` is already the per-row array this mode
|
||||||
// exactly, so a real per-assignee dropdown would need `Table`
|
// wants, so there's no reason to fall back to a joined-string
|
||||||
// itself to grow multi-value filtering. Substring search over
|
// substring search here.
|
||||||
// the joined string still finds "iris" inside "damocles, iris"
|
filterValues: (r) => r.assignees,
|
||||||
// correctly, which covers the actual gap (no way to filter by
|
filterMode: "multiselect",
|
||||||
// assignee at all today).
|
|
||||||
filterValue: (r) => r.assignees.join(", "),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "blocked",
|
key: "blocked",
|
||||||
|
|
@ -337,11 +335,11 @@ export function IssueReportPage() {
|
||||||
r.blocked ? <Badge tone="warning" value="blocked" /> : "—",
|
r.blocked ? <Badge tone="warning" value="blocked" /> : "—",
|
||||||
// A distinct gap from the existing "hide blocked" toggle above:
|
// A distinct gap from the existing "hide blocked" toggle above:
|
||||||
// that one only *hides* blocked issues, there was no way to see
|
// that one only *hides* blocked issues, there was no way to see
|
||||||
// *only* the blocked ones. Synthesized two-value string (not the
|
// *only* the blocked ones. Synthesized two-value set (not the raw
|
||||||
// raw boolean) — `Table`'s select mode needs a string to match,
|
// boolean) — multiselect, same as every other enum-shaped column,
|
||||||
// same as `AgentsPage`'s `wanted ?? "no declaration"` pattern.
|
// rather than the single-value `"select"` mode.
|
||||||
filterValue: (r) => (r.blocked ? "blocked" : "not blocked"),
|
filterValues: (r) => [r.blocked ? "blocked" : "not blocked"],
|
||||||
filterMode: "select",
|
filterMode: "multiselect",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "depended_on_by_count",
|
key: "depended_on_by_count",
|
||||||
|
|
|
||||||
|
|
@ -129,8 +129,7 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
.ui-table-filter-input,
|
.ui-table-filter-input {
|
||||||
.ui-table-filter-select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|
|
||||||
|
|
@ -77,22 +77,24 @@ export interface TableColumn<T> {
|
||||||
/**
|
/**
|
||||||
* `"text"` (default): free substring, case-insensitive `<input>` —
|
* `"text"` (default): free substring, case-insensitive `<input>` —
|
||||||
* today's only behavior, unaffected if you don't set this.
|
* today's only behavior, unaffected if you don't set this.
|
||||||
* `"select"`: a `<select>` populated from the *distinct* `filterValue`
|
* `"multiselect"`: a checkbox list, populated from the *distinct*
|
||||||
* results across the currently-loaded rows (deduped, case-insensitive
|
* `filterValues` results across the currently-loaded rows (deduped,
|
||||||
* sort) plus a leading "any" option, matched by exact equality. Pick
|
* case-insensitive sort), matching on any overlap between the row's
|
||||||
* this for a column whose value only ever comes from a small
|
* values and the operator's current selection. Pick this for a column
|
||||||
* bounded/closed set (an enum label, a hive name) — typing an exact
|
* whose value only ever comes from a small bounded/closed set (an enum
|
||||||
* substring is friction a picker removes, and a substring query can
|
* label, a hive name) — typing an exact substring is friction a picker
|
||||||
* accidentally straddle two distinct values. Deriving options from
|
* removes, and a substring query can accidentally straddle two
|
||||||
* live rows rather than a hardcoded enum means the list never offers
|
* distinct values. Works the same whether a row naturally carries a
|
||||||
* a choice that would match zero rows. Ignored unless `filterValue`
|
* *set* of values (e.g. issue labels — pass the real array) or just
|
||||||
* is also set.
|
* one (wrap it in a 1-element array). Deriving options from live rows
|
||||||
* `"multiselect"`: a checkbox list, same live-rows-derived option
|
* rather than a hardcoded enum means the list never offers a choice
|
||||||
* source as `"select"` but over `filterValues` and matching on any
|
* that would match zero rows. Ignored unless `filterValues` is set.
|
||||||
* overlap rather than one exact value — for a column whose row value
|
* There's deliberately no separate single-value `"select"` mode —
|
||||||
* is itself a set (e.g. labels). Ignored unless `filterValues` is set.
|
* multiselect subsumes it (mara: "why have a non multi select? the
|
||||||
|
* user can always just choose 1"), so a single-choice picker never
|
||||||
|
* needs distinct handling from an N-choice one.
|
||||||
*/
|
*/
|
||||||
filterMode?: "text" | "select" | "multiselect";
|
filterMode?: "text" | "multiselect";
|
||||||
}
|
}
|
||||||
|
|
||||||
// One filter's full state: `value` for `"text"`/`"select"`, `values` for
|
// One filter's full state: `value` for `"text"`/`"select"`, `values` for
|
||||||
|
|
@ -290,9 +292,6 @@ export function Table<T>({
|
||||||
if (col?.filterMode === "multiselect") {
|
if (col?.filterMode === "multiselect") {
|
||||||
const rowValues = col.filterValues?.(row) ?? [];
|
const rowValues = col.filterValues?.(row) ?? [];
|
||||||
matches = f.values.some((v) => rowValues.includes(v));
|
matches = f.values.some((v) => rowValues.includes(v));
|
||||||
} else if (col?.filterMode === "select") {
|
|
||||||
const value = col.filterValue?.(row) ?? "";
|
|
||||||
matches = value === f.value;
|
|
||||||
} else {
|
} else {
|
||||||
const value = col?.filterValue?.(row) ?? "";
|
const value = col?.filterValue?.(row) ?? "";
|
||||||
matches = value.toLowerCase().includes(f.value.toLowerCase());
|
matches = value.toLowerCase().includes(f.value.toLowerCase());
|
||||||
|
|
@ -334,22 +333,13 @@ export function Table<T>({
|
||||||
return sort.dir === "asc" ? "ascending" : "descending";
|
return sort.dir === "asc" ? "ascending" : "descending";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distinct values currently present for a `"select"`-mode column, so
|
// Distinct values currently present for a `"multiselect"`-mode column,
|
||||||
// the dropdown never offers an option that would match zero rows.
|
// so the checkbox list never offers an option that would match zero
|
||||||
// Computed off `rows` (pre-filter) — every column's own option list
|
// rows. Computed off `rows` (pre-filter) — every column's own option
|
||||||
// stays stable while a sibling column's filter narrows `visibleRows`,
|
// list stays stable while a sibling column's filter narrows
|
||||||
// matching how a spreadsheet's column filters don't hide each other's
|
// `visibleRows`, matching how a spreadsheet's column filters don't
|
||||||
// choices.
|
// hide each other's choices. A row contributes every one of its own
|
||||||
function selectOptionsFor(c: TableColumn<T>): string[] {
|
// values, not just one.
|
||||||
if (!c.filterValue) return [];
|
|
||||||
const values = new Set(rows.map(c.filterValue));
|
|
||||||
return Array.from(values).sort((a, b) =>
|
|
||||||
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same reasoning as `selectOptionsFor` above, over `filterValues`
|
|
||||||
// (a row contributes every one of its own values, not just one).
|
|
||||||
function multiselectOptionsFor(c: TableColumn<T>): string[] {
|
function multiselectOptionsFor(c: TableColumn<T>): string[] {
|
||||||
if (!c.filterValues) return [];
|
if (!c.filterValues) return [];
|
||||||
const values = new Set<string>();
|
const values = new Set<string>();
|
||||||
|
|
@ -362,17 +352,10 @@ export function Table<T>({
|
||||||
// Only one popover is ever open at a time, so one ref (rather than a
|
// Only one popover is ever open at a time, so one ref (rather than a
|
||||||
// per-column ref map) is enough to focus whichever control just
|
// per-column ref map) is enough to focus whichever control just
|
||||||
// mounted — typing immediately after the click that opened it,
|
// mounted — typing immediately after the click that opened it,
|
||||||
// without a separate click into the field first. A callback ref
|
// without a separate click into the field first. (The multiselect
|
||||||
// (not `useRef`'s object form attached directly to the element)
|
// checkbox list below doesn't use this — there's no single "the"
|
||||||
// because the element is one of two different concrete types
|
// control to focus in a list of several.)
|
||||||
// depending on `filterMode` — `RefObject<HTMLInputElement |
|
const popoverControlRef = useRef<HTMLInputElement | null>(null);
|
||||||
// HTMLSelectElement>` doesn't structurally satisfy either element's
|
|
||||||
// own narrower `ref` prop type, but a plain callback taking the
|
|
||||||
// union does.
|
|
||||||
const popoverControlRef = useRef<HTMLElement | null>(null);
|
|
||||||
function setPopoverControlRef(el: HTMLElement | null) {
|
|
||||||
popoverControlRef.current = el;
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openFilterKey !== null) popoverControlRef.current?.focus();
|
if (openFilterKey !== null) popoverControlRef.current?.focus();
|
||||||
}, [openFilterKey]);
|
}, [openFilterKey]);
|
||||||
|
|
@ -423,34 +406,10 @@ export function Table<T>({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (c.filterMode === "select") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<select
|
|
||||||
ref={setPopoverControlRef}
|
|
||||||
class="ui-table-filter-select"
|
|
||||||
aria-label={filterLabel(c)}
|
|
||||||
value={getFilter(c.key).value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = (e.target as HTMLSelectElement).value;
|
|
||||||
updateFilter(c.key, { value: v });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">any</option>
|
|
||||||
{selectOptionsFor(c).map((v) => (
|
|
||||||
<option key={v} value={v}>
|
|
||||||
{v}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{renderNegateToggle(c)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
ref={setPopoverControlRef}
|
ref={popoverControlRef}
|
||||||
type="text"
|
type="text"
|
||||||
class="ui-table-filter-input"
|
class="ui-table-filter-input"
|
||||||
placeholder="filter…"
|
placeholder="filter…"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue