@runwisp/ui 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/astro/SiteFooter.astro +149 -31
- package/src/lib/actions/row-motion.test.ts +115 -0
- package/src/lib/actions/row-motion.ts +75 -0
- package/src/lib/components/AlertDialog.svelte +63 -25
- package/src/lib/components/Button.svelte +1 -1
- package/src/lib/components/DataGrid.svelte +151 -90
- package/src/lib/components/FilterBar.svelte +156 -0
- package/src/lib/components/FilterPill.svelte +180 -0
- package/src/lib/components/NotificationBell.svelte +70 -0
- package/src/lib/components/SearchInput.svelte +91 -0
- package/src/lib/components/StatusDot.svelte +38 -0
- package/src/lib/components/ThemeToggle.svelte +28 -33
- package/src/lib/components/dashboard/RunDetailPanel.svelte +31 -1
- package/src/lib/components/dashboard/RunsList.svelte +28 -4
- package/src/lib/components/dashboard/run-filters.test.ts +10 -1
- package/src/lib/components/dashboard/run-filters.ts +4 -2
- package/src/lib/components/dashboard/run-helpers.ts +1 -0
- package/src/lib/components/filter-spec.ts +94 -0
- package/src/lib/index.ts +28 -1
- package/src/lib/theme.css +39 -0
- package/src/lib/utils/debounce.test.ts +31 -0
- package/src/lib/utils/debounce.ts +22 -0
- package/src/lib/utils/filter.test.ts +172 -0
- package/src/lib/utils/filter.ts +79 -0
- package/src/lib/utils/run-motion.test.ts +67 -0
- package/src/lib/utils/run-motion.ts +56 -0
- package/src/lib/utils/theme.svelte.test.ts +142 -0
- package/src/lib/utils/theme.svelte.ts +71 -0
- package/src/stories/DataGrid.stories.svelte +64 -1
- package/src/stories/FilterBar.stories.svelte +61 -0
- package/src/stories/Welcome.mdx +1 -1
- package/src/astro/CTABand.astro +0 -39
- package/src/astro/Hero.astro +0 -58
- package/src/astro/SiteHeader.astro +0 -46
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Declarative filter spec. A `FilterField[]` + one bound `FilterValues` object
|
|
5
|
+
// drives <FilterBar>, the client engine (`applyFilters`), and the URL sync
|
|
6
|
+
// helper — the same way DataGrid's `Column[]` drives the table. Add a field
|
|
7
|
+
// type here, and every table that adopts it renders identically.
|
|
8
|
+
|
|
9
|
+
import type { SelectOption } from "./Select.svelte";
|
|
10
|
+
|
|
11
|
+
// `path` (where present) is the dot-path read on the row for client-side
|
|
12
|
+
// matching; it defaults to `key`. `key` is also the slot in `FilterValues` and
|
|
13
|
+
// the URL query param. Search fields test `fields` (dot-paths) instead.
|
|
14
|
+
export type FilterField =
|
|
15
|
+
| {
|
|
16
|
+
type: "search";
|
|
17
|
+
key: string;
|
|
18
|
+
fields: string[];
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
primary?: boolean;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
type: "select";
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
options: SelectOption[];
|
|
27
|
+
path?: string;
|
|
28
|
+
primary?: boolean;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
type: "number";
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
path?: string;
|
|
36
|
+
primary?: boolean;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
// Stored as two values: `${key}From` / `${key}To` (yyyy-mm-dd).
|
|
40
|
+
type: "daterange";
|
|
41
|
+
key: string;
|
|
42
|
+
label: string;
|
|
43
|
+
path?: string;
|
|
44
|
+
primary?: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// All values are strings — DOM inputs are string-valued and URL params are
|
|
48
|
+
// strings; the client engine coerces (e.g. Number()) where a field needs it.
|
|
49
|
+
export type FilterValues = Record<string, string>;
|
|
50
|
+
|
|
51
|
+
// The full state a list emits via onQueryChange: FilterBar values + sort +
|
|
52
|
+
// pagination. The host mirrors this to the URL; server-mode lists also
|
|
53
|
+
// map it to REST query params.
|
|
54
|
+
export interface TableQuery {
|
|
55
|
+
values: FilterValues;
|
|
56
|
+
sortKey?: string;
|
|
57
|
+
sortDir?: "asc" | "desc";
|
|
58
|
+
page: number;
|
|
59
|
+
pageSize: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A select value is "inactive" (matches everything) when blank, undefined, or
|
|
63
|
+
// the conventional "all" sentinel some existing option lists use.
|
|
64
|
+
export function isBlank(v: string | undefined): boolean {
|
|
65
|
+
return !v || v === "all";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Which value keys a field owns — daterange owns two, everything else one.
|
|
69
|
+
// Used by chips, active-state detection, clear, and URL (de)serialization so
|
|
70
|
+
// none of them has to special-case the range type.
|
|
71
|
+
export function fieldKeys(field: FilterField): string[] {
|
|
72
|
+
return field.type === "daterange" ? [`${field.key}From`, `${field.key}To`] : [field.key];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isFieldActive(field: FilterField, values: FilterValues): boolean {
|
|
76
|
+
return fieldKeys(field).some((k) => !isBlank(values[k]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Human label for a field. Every type but `search` carries one; search has no
|
|
80
|
+
// label (its box speaks for itself), so fall back to the key for the narrower.
|
|
81
|
+
export function fieldLabel(field: FilterField): string {
|
|
82
|
+
return "label" in field ? field.label : field.key;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Seed a values object with every field's keys present (blank), overlaid with
|
|
86
|
+
// any initial values (e.g. from the URL). Keeping all keys present up front
|
|
87
|
+
// keeps the emitted query reactive to each field and the bindings simple.
|
|
88
|
+
export function seedValues(fields: FilterField[], initial: FilterValues = {}): FilterValues {
|
|
89
|
+
const values: FilterValues = {};
|
|
90
|
+
for (const f of fields) {
|
|
91
|
+
for (const k of fieldKeys(f)) values[k] = initial[k] ?? "";
|
|
92
|
+
}
|
|
93
|
+
return values;
|
|
94
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { default as FormField } from "./components/FormField.svelte";
|
|
|
33
33
|
export { default as Input } from "./components/Input.svelte";
|
|
34
34
|
export { default as Radio } from "./components/Radio.svelte";
|
|
35
35
|
export { default as RadioGroup } from "./components/RadioGroup.svelte";
|
|
36
|
-
export { default as Select } from "./components/Select.svelte";
|
|
36
|
+
export { default as Select, type SelectOption } from "./components/Select.svelte";
|
|
37
37
|
export { default as Textarea } from "./components/Textarea.svelte";
|
|
38
38
|
export { default as TimezoneSelect } from "./components/TimezoneSelect.svelte";
|
|
39
39
|
export { default as Toggle } from "./components/Toggle.svelte";
|
|
@@ -47,9 +47,25 @@ export { default as Avatar } from "./components/Avatar.svelte";
|
|
|
47
47
|
export { default as Badge } from "./components/Badge.svelte";
|
|
48
48
|
export { default as Card } from "./components/Card.svelte";
|
|
49
49
|
export { default as DataGrid } from "./components/DataGrid.svelte";
|
|
50
|
+
export type { Column, DataGridProps } from "./components/DataGrid.svelte";
|
|
51
|
+
export { default as FilterBar } from "./components/FilterBar.svelte";
|
|
52
|
+
export { default as FilterPill } from "./components/FilterPill.svelte";
|
|
53
|
+
export { default as SearchInput } from "./components/SearchInput.svelte";
|
|
54
|
+
export {
|
|
55
|
+
fieldKeys,
|
|
56
|
+
fieldLabel,
|
|
57
|
+
isBlank,
|
|
58
|
+
isFieldActive,
|
|
59
|
+
seedValues,
|
|
60
|
+
type FilterField,
|
|
61
|
+
type FilterValues,
|
|
62
|
+
type TableQuery,
|
|
63
|
+
} from "./components/filter-spec.js";
|
|
64
|
+
export { applyFilters, matchesQuery, resolvePath } from "./utils/filter.js";
|
|
50
65
|
export { default as Progress } from "./components/Progress.svelte";
|
|
51
66
|
export { default as Skeleton } from "./components/Skeleton.svelte";
|
|
52
67
|
export { default as Sparkline } from "./components/Sparkline.svelte";
|
|
68
|
+
export { default as StatusDot, type StatusDotTone } from "./components/StatusDot.svelte";
|
|
53
69
|
export { default as StatusIndicator } from "./components/StatusIndicator.svelte";
|
|
54
70
|
export { default as Table } from "./components/Table.svelte";
|
|
55
71
|
export { default as TableBody } from "./components/TableBody.svelte";
|
|
@@ -64,9 +80,11 @@ export { default as Dropdown } from "./components/Dropdown.svelte";
|
|
|
64
80
|
export { default as EmptyState } from "./components/EmptyState.svelte";
|
|
65
81
|
export { default as ErrorState } from "./components/ErrorState.svelte";
|
|
66
82
|
export { default as Modal } from "./components/Modal.svelte";
|
|
83
|
+
export { default as NotificationBell } from "./components/NotificationBell.svelte";
|
|
67
84
|
export { default as Popover } from "./components/Popover.svelte";
|
|
68
85
|
export { default as ToastContainer } from "./components/ToastContainer.svelte";
|
|
69
86
|
export { default as Tooltip } from "./components/Tooltip.svelte";
|
|
87
|
+
export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
70
88
|
|
|
71
89
|
export { default as CodeBlock } from "./components/CodeBlock.svelte";
|
|
72
90
|
export { default as TaskCard } from "./components/TaskCard.svelte";
|
|
@@ -118,6 +136,12 @@ export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-fo
|
|
|
118
136
|
export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
|
|
119
137
|
|
|
120
138
|
export { toast, type Toast, type ToastType } from "./utils/toast.svelte.js";
|
|
139
|
+
export {
|
|
140
|
+
themeStore,
|
|
141
|
+
THEME_STORAGE_KEY,
|
|
142
|
+
type ThemePreference,
|
|
143
|
+
type ResolvedTheme,
|
|
144
|
+
} from "./utils/theme.svelte.js";
|
|
121
145
|
export { extractErrorMessage } from "./utils/error.js";
|
|
122
146
|
export {
|
|
123
147
|
formatBytes,
|
|
@@ -128,4 +152,7 @@ export {
|
|
|
128
152
|
formatFullDateTime,
|
|
129
153
|
} from "./utils/format.js";
|
|
130
154
|
export { formatShortId } from "./utils/id.js";
|
|
155
|
+
export { debounce } from "./utils/debounce.js";
|
|
131
156
|
export { TickingNow } from "./utils/ticking-now.svelte.js";
|
|
157
|
+
export { RunMotion } from "./utils/run-motion.js";
|
|
158
|
+
export { arrival, leave, shift, prefersReducedMotion } from "./actions/row-motion.js";
|
package/src/lib/theme.css
CHANGED
|
@@ -298,3 +298,42 @@ h3 {
|
|
|
298
298
|
.btn-scale:active {
|
|
299
299
|
transform: scale(0.97);
|
|
300
300
|
}
|
|
301
|
+
|
|
302
|
+
/* Live arrival: the row drops in, then a primary ring glows and fades so the
|
|
303
|
+
eye finds the new run. Added by the `arrival` action; `translate` composes
|
|
304
|
+
with any inline `transform`. */
|
|
305
|
+
.run-arrive {
|
|
306
|
+
border-radius: 3px;
|
|
307
|
+
animation: run-arrive 1.2s ease-out;
|
|
308
|
+
}
|
|
309
|
+
@keyframes run-arrive {
|
|
310
|
+
0% {
|
|
311
|
+
opacity: 0;
|
|
312
|
+
translate: 0 -6px;
|
|
313
|
+
box-shadow: 0 0 0 1px var(--color-primary);
|
|
314
|
+
}
|
|
315
|
+
18% {
|
|
316
|
+
opacity: 1;
|
|
317
|
+
translate: 0 0;
|
|
318
|
+
box-shadow:
|
|
319
|
+
0 0 0 1px var(--color-primary),
|
|
320
|
+
0 0 12px color-mix(in srgb, var(--color-primary) 35%, transparent);
|
|
321
|
+
}
|
|
322
|
+
100% {
|
|
323
|
+
box-shadow: 0 0 0 1px transparent;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
@keyframes run-arrive-still {
|
|
327
|
+
0%,
|
|
328
|
+
18% {
|
|
329
|
+
box-shadow: 0 0 0 1px var(--color-primary);
|
|
330
|
+
}
|
|
331
|
+
100% {
|
|
332
|
+
box-shadow: 0 0 0 1px transparent;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
@media (prefers-reduced-motion: reduce) {
|
|
336
|
+
.run-arrive {
|
|
337
|
+
animation-name: run-arrive-still;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { debounce } from "./debounce.js";
|
|
6
|
+
|
|
7
|
+
describe("debounce", () => {
|
|
8
|
+
beforeEach(() => vi.useFakeTimers());
|
|
9
|
+
afterEach(() => vi.useRealTimers());
|
|
10
|
+
|
|
11
|
+
it("collapses rapid calls into one trailing call with the last args", () => {
|
|
12
|
+
const fn = vi.fn();
|
|
13
|
+
const d = debounce(fn, 20);
|
|
14
|
+
d(1);
|
|
15
|
+
d(2);
|
|
16
|
+
d(3);
|
|
17
|
+
expect(fn).not.toHaveBeenCalled();
|
|
18
|
+
vi.advanceTimersByTime(20);
|
|
19
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
20
|
+
expect(fn).toHaveBeenCalledWith(3);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("cancel drops the pending call", () => {
|
|
24
|
+
const fn = vi.fn();
|
|
25
|
+
const d = debounce(fn, 20);
|
|
26
|
+
d();
|
|
27
|
+
d.cancel();
|
|
28
|
+
vi.advanceTimersByTime(40);
|
|
29
|
+
expect(fn).not.toHaveBeenCalled();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Trailing-edge debounce. Returns a wrapper that defers `fn` until `ms` have
|
|
5
|
+
// elapsed since the last call; `.cancel()` drops a pending call (used when an
|
|
6
|
+
// external write must win immediately, e.g. Reset clearing a debounced field).
|
|
7
|
+
export function debounce<A extends unknown[]>(
|
|
8
|
+
fn: (...args: A) => void,
|
|
9
|
+
ms: number,
|
|
10
|
+
): ((...args: A) => void) & { cancel: () => void } {
|
|
11
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
12
|
+
const wrapped = (...args: A) => {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
timer = setTimeout(() => {
|
|
15
|
+
fn(...args);
|
|
16
|
+
}, ms);
|
|
17
|
+
};
|
|
18
|
+
wrapped.cancel = () => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
};
|
|
21
|
+
return wrapped;
|
|
22
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { applyFilters, matchesQuery, resolvePath } from "./filter.js";
|
|
6
|
+
import {
|
|
7
|
+
type FilterField,
|
|
8
|
+
fieldKeys,
|
|
9
|
+
fieldLabel,
|
|
10
|
+
isBlank,
|
|
11
|
+
isFieldActive,
|
|
12
|
+
seedValues,
|
|
13
|
+
} from "../components/filter-spec.js";
|
|
14
|
+
|
|
15
|
+
interface Row {
|
|
16
|
+
id: string;
|
|
17
|
+
status: string;
|
|
18
|
+
exitCode: number;
|
|
19
|
+
roles: string[];
|
|
20
|
+
user: { name: string };
|
|
21
|
+
startedAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rows: Row[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "a",
|
|
27
|
+
status: "ok",
|
|
28
|
+
exitCode: 0,
|
|
29
|
+
roles: ["owner"],
|
|
30
|
+
user: { name: "Ada" },
|
|
31
|
+
startedAt: new Date("2026-01-10"),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "b",
|
|
35
|
+
status: "err",
|
|
36
|
+
exitCode: 1,
|
|
37
|
+
roles: ["viewer"],
|
|
38
|
+
user: { name: "Bob" },
|
|
39
|
+
startedAt: new Date("2026-02-20"),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "c",
|
|
43
|
+
status: "ok",
|
|
44
|
+
exitCode: 2,
|
|
45
|
+
roles: ["owner", "viewer"],
|
|
46
|
+
user: { name: "Cy" },
|
|
47
|
+
startedAt: new Date("2026-03-30"),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const range: FilterField = { type: "daterange", key: "startedAt", label: "Date" };
|
|
52
|
+
const search: FilterField = { type: "search", key: "q", fields: ["id", "user.name"] };
|
|
53
|
+
const status: FilterField = { type: "select", key: "status", label: "Status", options: [] };
|
|
54
|
+
|
|
55
|
+
const fields: FilterField[] = [
|
|
56
|
+
search,
|
|
57
|
+
status,
|
|
58
|
+
{ type: "select", key: "role", label: "Role", path: "roles", options: [] },
|
|
59
|
+
{ type: "number", key: "exitCode", label: "Exit" },
|
|
60
|
+
range,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const ids = (r: Row[]) => r.map((x) => x.id);
|
|
64
|
+
|
|
65
|
+
describe("applyFilters", () => {
|
|
66
|
+
it("passes everything through when values are empty", () => {
|
|
67
|
+
expect(applyFilters(rows, fields, {})).toHaveLength(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("searches dot-paths case-insensitively", () => {
|
|
71
|
+
expect(ids(applyFilters(rows, fields, { q: "ad" }))).toEqual(["a"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("matches select by equality and treats 'all' as inactive", () => {
|
|
75
|
+
expect(ids(applyFilters(rows, fields, { status: "ok" }))).toEqual(["a", "c"]);
|
|
76
|
+
expect(applyFilters(rows, fields, { status: "all" })).toHaveLength(3);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("matches select against an array cell by membership", () => {
|
|
80
|
+
expect(ids(applyFilters(rows, fields, { role: "viewer" }))).toEqual(["b", "c"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("coerces number values", () => {
|
|
84
|
+
expect(ids(applyFilters(rows, fields, { exitCode: "1" }))).toEqual(["b"]);
|
|
85
|
+
expect(ids(applyFilters(rows, fields, { exitCode: "0" }))).toEqual(["a"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("filters a date range inclusively on both ends", () => {
|
|
89
|
+
expect(
|
|
90
|
+
ids(
|
|
91
|
+
applyFilters(rows, fields, {
|
|
92
|
+
startedAtFrom: "2026-02-01",
|
|
93
|
+
startedAtTo: "2026-03-30",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
).toEqual(["b", "c"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("ANDs combined filters", () => {
|
|
100
|
+
expect(ids(applyFilters(rows, fields, { status: "ok", role: "viewer" }))).toEqual(["c"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("coerces non-string select cells (number, boolean, date) and skips the rest", () => {
|
|
104
|
+
interface Flagged {
|
|
105
|
+
id: string;
|
|
106
|
+
flags: unknown[];
|
|
107
|
+
}
|
|
108
|
+
const flaggedRows: Flagged[] = [
|
|
109
|
+
{ id: "a", flags: [1, true, new Date("2026-01-01T00:00:00.000Z"), {}] },
|
|
110
|
+
{ id: "b", flags: [0, false] },
|
|
111
|
+
];
|
|
112
|
+
const flagField: FilterField = {
|
|
113
|
+
type: "select",
|
|
114
|
+
key: "flag",
|
|
115
|
+
label: "Flag",
|
|
116
|
+
path: "flags",
|
|
117
|
+
options: [],
|
|
118
|
+
};
|
|
119
|
+
const flagIds = (r: Flagged[]) => r.map((x) => x.id);
|
|
120
|
+
expect(flagIds(applyFilters(flaggedRows, [flagField], { flag: "1" }))).toEqual(["a"]);
|
|
121
|
+
expect(flagIds(applyFilters(flaggedRows, [flagField], { flag: "true" }))).toEqual(["a"]);
|
|
122
|
+
expect(
|
|
123
|
+
flagIds(
|
|
124
|
+
applyFilters(flaggedRows, [flagField], {
|
|
125
|
+
flag: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
).toEqual(["a"]);
|
|
129
|
+
expect(applyFilters(flaggedRows, [flagField], { flag: "no-such-value" })).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("filter helpers", () => {
|
|
134
|
+
it("matchesQuery skips nullish fields and matches empty queries", () => {
|
|
135
|
+
expect(matchesQuery("", null)).toBe(true);
|
|
136
|
+
expect(matchesQuery("x", null, undefined)).toBe(false);
|
|
137
|
+
expect(matchesQuery("OB", "bob")).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("resolvePath reads nested fields and misses gracefully", () => {
|
|
141
|
+
expect(resolvePath(rows[0], "user.name")).toBe("Ada");
|
|
142
|
+
expect(resolvePath(rows[0], "user.missing.deep")).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("isBlank treats blank, undefined, and 'all' as inactive", () => {
|
|
146
|
+
expect(isBlank(undefined)).toBe(true);
|
|
147
|
+
expect(isBlank("")).toBe(true);
|
|
148
|
+
expect(isBlank("all")).toBe(true);
|
|
149
|
+
expect(isBlank("0")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("fieldLabel uses the label, falling back to the key for search fields", () => {
|
|
153
|
+
expect(fieldLabel(status)).toBe("Status");
|
|
154
|
+
expect(fieldLabel(search)).toBe("q");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("daterange owns two value keys", () => {
|
|
158
|
+
expect(fieldKeys(range)).toEqual(["startedAtFrom", "startedAtTo"]);
|
|
159
|
+
expect(isFieldActive(range, { startedAtTo: "2026-01-01" })).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("seedValues fills every key and overlays initial values", () => {
|
|
163
|
+
expect(seedValues(fields, { status: "ok", extra: "dropped" })).toEqual({
|
|
164
|
+
q: "",
|
|
165
|
+
status: "ok",
|
|
166
|
+
role: "",
|
|
167
|
+
exitCode: "",
|
|
168
|
+
startedAtFrom: "",
|
|
169
|
+
startedAtTo: "",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { type FilterField, type FilterValues, isBlank } from "../components/filter-spec.js";
|
|
5
|
+
|
|
6
|
+
// Case-insensitive substring match against any of the given fields. Empty
|
|
7
|
+
// query matches everything; nullish fields are skipped.
|
|
8
|
+
export function matchesQuery(query: string, ...fields: (string | null | undefined)[]): boolean {
|
|
9
|
+
if (!query) return true;
|
|
10
|
+
const q = query.toLowerCase();
|
|
11
|
+
return fields.some((f) => f?.toLowerCase().includes(q) === true);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Dot-path aware field read: resolves "user.name" against nested objects.
|
|
15
|
+
// Shared by DataGrid's sort comparator and the filter engine below so both
|
|
16
|
+
// read fields the same way.
|
|
17
|
+
export function resolvePath(obj: unknown, path: string): unknown {
|
|
18
|
+
return path
|
|
19
|
+
.split(".")
|
|
20
|
+
.reduce<unknown>(
|
|
21
|
+
(acc, k): unknown =>
|
|
22
|
+
typeof acc === "object" && acc !== null ? Reflect.get(acc, k) : undefined,
|
|
23
|
+
obj,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Scalar cell → the string a filter value is compared against. Objects and
|
|
28
|
+
// nullish cells have no text form, so they never match a search or select.
|
|
29
|
+
function toText(v: unknown): string | null {
|
|
30
|
+
if (typeof v === "string") return v;
|
|
31
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
32
|
+
if (v instanceof Date) return v.toISOString();
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Array cell (e.g. member roles) → membership; scalar → equality.
|
|
37
|
+
function matchSelect(cell: unknown, v: string): boolean {
|
|
38
|
+
return Array.isArray(cell) ? cell.some((c) => toText(c) === v) : toText(cell) === v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Inclusive yyyy-mm-dd range. The ISO date portion sorts lexically, so a
|
|
42
|
+
// string compare is enough.
|
|
43
|
+
function matchDateRange(cell: unknown, from: string | undefined, to: string | undefined): boolean {
|
|
44
|
+
if (isBlank(from) && isBlank(to)) return true;
|
|
45
|
+
const d = cell instanceof Date ? cell : new Date(toText(cell) ?? "");
|
|
46
|
+
if (Number.isNaN(d.getTime())) return false;
|
|
47
|
+
const day = d.toISOString().slice(0, 10);
|
|
48
|
+
return (isBlank(from) || day >= (from ?? "")) && (isBlank(to) || day <= (to ?? ""));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchField(row: unknown, field: FilterField, values: FilterValues): boolean {
|
|
52
|
+
switch (field.type) {
|
|
53
|
+
case "search":
|
|
54
|
+
return matchesQuery(
|
|
55
|
+
values[field.key] ?? "",
|
|
56
|
+
...field.fields.map((p) => toText(resolvePath(row, p))),
|
|
57
|
+
);
|
|
58
|
+
case "select": {
|
|
59
|
+
const v = values[field.key];
|
|
60
|
+
return isBlank(v) || matchSelect(resolvePath(row, field.path ?? field.key), v ?? "");
|
|
61
|
+
}
|
|
62
|
+
case "number": {
|
|
63
|
+
const v = values[field.key];
|
|
64
|
+
return isBlank(v) || Number(resolvePath(row, field.path ?? field.key)) === Number(v);
|
|
65
|
+
}
|
|
66
|
+
case "daterange":
|
|
67
|
+
return matchDateRange(
|
|
68
|
+
resolvePath(row, field.path ?? field.key),
|
|
69
|
+
values[`${field.key}From`],
|
|
70
|
+
values[`${field.key}To`],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Client-side filter engine: a row survives when every field matches.
|
|
76
|
+
// Server-mode tables map the same `values` to query params instead.
|
|
77
|
+
export function applyFilters<T>(rows: T[], fields: FilterField[], values: FilterValues): T[] {
|
|
78
|
+
return rows.filter((row) => fields.every((f) => matchField(row, f, values)));
|
|
79
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { RunMotion } from "./run-motion.js";
|
|
6
|
+
|
|
7
|
+
const WINDOW_MS = 2000;
|
|
8
|
+
|
|
9
|
+
describe("RunMotion", () => {
|
|
10
|
+
beforeEach(() => vi.useFakeTimers());
|
|
11
|
+
afterEach(() => vi.useRealTimers());
|
|
12
|
+
|
|
13
|
+
it("reports nothing for a run it was never told about", () => {
|
|
14
|
+
const motion = new RunMotion();
|
|
15
|
+
expect(motion.arrived("run-1")).toBe(false);
|
|
16
|
+
expect(motion.removed("run-1")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("keeps arrivals and removals apart", () => {
|
|
20
|
+
const motion = new RunMotion();
|
|
21
|
+
motion.markArrived("arrived-run");
|
|
22
|
+
motion.markRemoved("removed-run");
|
|
23
|
+
expect(motion.arrived("arrived-run")).toBe(true);
|
|
24
|
+
expect(motion.removed("arrived-run")).toBe(false);
|
|
25
|
+
expect(motion.removed("removed-run")).toBe(true);
|
|
26
|
+
expect(motion.arrived("removed-run")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("forgets a run once the window passes, so a later click doesn't animate it", () => {
|
|
30
|
+
const motion = new RunMotion();
|
|
31
|
+
motion.markArrived("run-1");
|
|
32
|
+
motion.markRemoved("run-2");
|
|
33
|
+
vi.advanceTimersByTime(WINDOW_MS - 1);
|
|
34
|
+
expect(motion.arrived("run-1")).toBe(true);
|
|
35
|
+
expect(motion.removed("run-2")).toBe(true);
|
|
36
|
+
vi.advanceTimersByTime(1);
|
|
37
|
+
expect(motion.arrived("run-1")).toBe(false);
|
|
38
|
+
expect(motion.removed("run-2")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("prunes expired runs when a later one is marked", () => {
|
|
42
|
+
const motion = new RunMotion();
|
|
43
|
+
motion.markArrived("old-run");
|
|
44
|
+
vi.advanceTimersByTime(WINDOW_MS);
|
|
45
|
+
motion.markArrived("new-run");
|
|
46
|
+
expect(motion.arrived("old-run")).toBe(false);
|
|
47
|
+
expect(motion.arrived("new-run")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("re-marking a run restarts its window", () => {
|
|
51
|
+
const motion = new RunMotion();
|
|
52
|
+
motion.markArrived("run-1");
|
|
53
|
+
vi.advanceTimersByTime(WINDOW_MS - 1);
|
|
54
|
+
motion.markArrived("run-1");
|
|
55
|
+
vi.advanceTimersByTime(WINDOW_MS - 1);
|
|
56
|
+
expect(motion.arrived("run-1")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("clear forgets everything", () => {
|
|
60
|
+
const motion = new RunMotion();
|
|
61
|
+
motion.markArrived("arrived-run");
|
|
62
|
+
motion.markRemoved("removed-run");
|
|
63
|
+
motion.clear();
|
|
64
|
+
expect(motion.arrived("arrived-run")).toBe(false);
|
|
65
|
+
expect(motion.removed("removed-run")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Long enough to span an SSE insert and the trigger's HTTP response that
|
|
5
|
+
// selects the new run; short enough that a later click never re-animates it.
|
|
6
|
+
const WINDOW_MS = 2000;
|
|
7
|
+
|
|
8
|
+
class RecentIds {
|
|
9
|
+
readonly #at = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
mark(id: string): void {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [known, at] of this.#at) {
|
|
14
|
+
if (now - at >= WINDOW_MS) this.#at.delete(known);
|
|
15
|
+
}
|
|
16
|
+
this.#at.set(id, now);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
has(id: string): boolean {
|
|
20
|
+
const at = this.#at.get(id);
|
|
21
|
+
return at !== undefined && Date.now() - at < WINDOW_MS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
clear(): void {
|
|
25
|
+
this.#at.clear();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Remembers which runs arrived or were removed live moments ago (a trigger, a
|
|
31
|
+
* scheduled firing, a run finishing, a delete), as opposed to rows that a page
|
|
32
|
+
* fetch, scroll or filter change loaded or dropped, so only genuine changes
|
|
33
|
+
* animate. Plain (non-reactive): it is only read at mount, unmount or
|
|
34
|
+
* selection time.
|
|
35
|
+
*/
|
|
36
|
+
export class RunMotion {
|
|
37
|
+
readonly #arrived = new RecentIds();
|
|
38
|
+
readonly #removed = new RecentIds();
|
|
39
|
+
|
|
40
|
+
markArrived(runId: string): void {
|
|
41
|
+
this.#arrived.mark(runId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
markRemoved(runId: string): void {
|
|
45
|
+
this.#removed.mark(runId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Arrows so they can be handed around unbound.
|
|
49
|
+
arrived = (runId: string): boolean => this.#arrived.has(runId);
|
|
50
|
+
removed = (runId: string): boolean => this.#removed.has(runId);
|
|
51
|
+
|
|
52
|
+
clear(): void {
|
|
53
|
+
this.#arrived.clear();
|
|
54
|
+
this.#removed.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|