Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
5 changed files with 96 additions and 59 deletions

View file

@ -318,13 +318,11 @@ 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 list reads // "—" (not "") so the missing-hive option in the filter dropdown
// the same as the cell itself, rather than showing a blank choice. // reads the same as the cell itself, rather than showing a blank
// Multiselect, not single-value `"select"` — an operator narrowing // choice.
// to a handful of hives at once shouldn't need to filter one at a filterValue: (a) => a.hive ?? "—",
// time. filterMode: "select",
filterValues: (a) => [a.hive ?? "—"],
filterMode: "multiselect",
}, },
{ {
key: "status", key: "status",
@ -335,9 +333,8 @@ 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,
// Multiselect, same reasoning as the hive column above. filterValue: (a) => FRESHNESS[a.freshness].label,
filterValues: (a) => [FRESHNESS[a.freshness].label], filterMode: "select",
filterMode: "multiselect",
render: (a) => { render: (a) => {
const { tone, label } = FRESHNESS[a.freshness]; const { tone, label } = FRESHNESS[a.freshness];
return ( return (
@ -382,9 +379,8 @@ 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 ?? "",
// Multiselect, same reasoning as the hive column above. filterValue: (a) => a.wanted ?? "no declaration",
filterValues: (a) => [a.wanted ?? "no declaration"], filterMode: "select",
filterMode: "multiselect",
render: (a) => { render: (a) => {
const err = actionErrors.get(a.name); const err = actionErrors.get(a.name);
return ( return (

View file

@ -91,11 +91,8 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
); );
}, },
sortBy: (h) => FRESHNESS[h.freshness].label, sortBy: (h) => FRESHNESS[h.freshness].label,
// Multiselect (not the single-value `"select"` mode) so the operator filterValue: (h) => FRESHNESS[h.freshness].label,
// can filter for e.g. "stale or offline" in one pass rather than one filterMode: "select",
// freshness state at a time.
filterValues: (h) => [FRESHNESS[h.freshness].label],
filterMode: "multiselect",
}, },
]; ];

View file

@ -312,12 +312,14 @@ 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(", ") : "—"),
// Multiselect, same machinery the "labels" column above already // Text, not `"select"` — a row can carry more than one assignee,
// uses — `r.assignees` is already the per-row array this mode // and `Table`'s select mode matches one whole string per row
// wants, so there's no reason to fall back to a joined-string // exactly, so a real per-assignee dropdown would need `Table`
// substring search here. // itself to grow multi-value filtering. Substring search over
filterValues: (r) => r.assignees, // the joined string still finds "iris" inside "damocles, iris"
filterMode: "multiselect", // correctly, which covers the actual gap (no way to filter by
// assignee at all today).
filterValue: (r) => r.assignees.join(", "),
}, },
{ {
key: "blocked", key: "blocked",
@ -335,11 +337,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 set (not the raw // *only* the blocked ones. Synthesized two-value string (not the
// boolean) — multiselect, same as every other enum-shaped column, // raw boolean) — `Table`'s select mode needs a string to match,
// rather than the single-value `"select"` mode. // same as `AgentsPage`'s `wanted ?? "no declaration"` pattern.
filterValues: (r) => [r.blocked ? "blocked" : "not blocked"], filterValue: (r) => (r.blocked ? "blocked" : "not blocked"),
filterMode: "multiselect", filterMode: "select",
}, },
{ {
key: "depended_on_by_count", key: "depended_on_by_count",

View file

@ -129,7 +129,8 @@
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);

View file

@ -77,24 +77,22 @@ 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.
* `"multiselect"`: a checkbox list, populated from the *distinct* * `"select"`: a `<select>` populated from the *distinct* `filterValue`
* `filterValues` results across the currently-loaded rows (deduped, * results across the currently-loaded rows (deduped, case-insensitive
* case-insensitive sort), matching on any overlap between the row's * sort) plus a leading "any" option, matched by exact equality. Pick
* values and the operator's current selection. Pick this for a column * this for a column whose value only ever comes from a small
* whose value only ever comes from a small bounded/closed set (an enum * bounded/closed set (an enum label, a hive name) — typing an exact
* label, a hive name) — typing an exact substring is friction a picker * substring is friction a picker removes, and a substring query can
* removes, and a substring query can accidentally straddle two * accidentally straddle two distinct values. Deriving options from
* distinct values. Works the same whether a row naturally carries a * live rows rather than a hardcoded enum means the list never offers
* *set* of values (e.g. issue labels — pass the real array) or just * a choice that would match zero rows. Ignored unless `filterValue`
* one (wrap it in a 1-element array). Deriving options from live rows * is also set.
* rather than a hardcoded enum means the list never offers a choice * `"multiselect"`: a checkbox list, same live-rows-derived option
* that would match zero rows. Ignored unless `filterValues` is set. * source as `"select"` but over `filterValues` and matching on any
* There's deliberately no separate single-value `"select"` mode — * overlap rather than one exact value — for a column whose row value
* multiselect subsumes it (mara: "why have a non multi select? the * is itself a set (e.g. labels). Ignored unless `filterValues` is set.
* user can always just choose 1"), so a single-choice picker never
* needs distinct handling from an N-choice one.
*/ */
filterMode?: "text" | "multiselect"; filterMode?: "text" | "select" | "multiselect";
} }
// One filter's full state: `value` for `"text"`/`"select"`, `values` for // One filter's full state: `value` for `"text"`/`"select"`, `values` for
@ -292,6 +290,9 @@ 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());
@ -333,13 +334,22 @@ export function Table<T>({
return sort.dir === "asc" ? "ascending" : "descending"; return sort.dir === "asc" ? "ascending" : "descending";
} }
// Distinct values currently present for a `"multiselect"`-mode column, // Distinct values currently present for a `"select"`-mode column, so
// so the checkbox list never offers an option that would match zero // the dropdown never offers an option that would match zero rows.
// rows. Computed off `rows` (pre-filter) — every column's own option // Computed off `rows` (pre-filter) — every column's own option list
// list stays stable while a sibling column's filter narrows // stays stable while a sibling column's filter narrows `visibleRows`,
// `visibleRows`, matching how a spreadsheet's column filters don't // matching how a spreadsheet's column filters don't hide each other's
// hide each other's choices. A row contributes every one of its own // choices.
// values, not just one. function selectOptionsFor(c: TableColumn<T>): string[] {
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>();
@ -352,10 +362,17 @@ 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. (The multiselect // without a separate click into the field first. A callback ref
// checkbox list below doesn't use this — there's no single "the" // (not `useRef`'s object form attached directly to the element)
// control to focus in a list of several.) // because the element is one of two different concrete types
const popoverControlRef = useRef<HTMLInputElement | null>(null); // depending on `filterMode` — `RefObject<HTMLInputElement |
// 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]);
@ -406,10 +423,34 @@ 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={popoverControlRef} ref={setPopoverControlRef}
type="text" type="text"
class="ui-table-filter-input" class="ui-table-filter-input"
placeholder="filter…" placeholder="filter…"