@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.
Files changed (35) hide show
  1. package/package.json +4 -4
  2. package/src/astro/SiteFooter.astro +149 -31
  3. package/src/lib/actions/row-motion.test.ts +115 -0
  4. package/src/lib/actions/row-motion.ts +75 -0
  5. package/src/lib/components/AlertDialog.svelte +63 -25
  6. package/src/lib/components/Button.svelte +1 -1
  7. package/src/lib/components/DataGrid.svelte +151 -90
  8. package/src/lib/components/FilterBar.svelte +156 -0
  9. package/src/lib/components/FilterPill.svelte +180 -0
  10. package/src/lib/components/NotificationBell.svelte +70 -0
  11. package/src/lib/components/SearchInput.svelte +91 -0
  12. package/src/lib/components/StatusDot.svelte +38 -0
  13. package/src/lib/components/ThemeToggle.svelte +28 -33
  14. package/src/lib/components/dashboard/RunDetailPanel.svelte +31 -1
  15. package/src/lib/components/dashboard/RunsList.svelte +28 -4
  16. package/src/lib/components/dashboard/run-filters.test.ts +10 -1
  17. package/src/lib/components/dashboard/run-filters.ts +4 -2
  18. package/src/lib/components/dashboard/run-helpers.ts +1 -0
  19. package/src/lib/components/filter-spec.ts +94 -0
  20. package/src/lib/index.ts +28 -1
  21. package/src/lib/theme.css +39 -0
  22. package/src/lib/utils/debounce.test.ts +31 -0
  23. package/src/lib/utils/debounce.ts +22 -0
  24. package/src/lib/utils/filter.test.ts +172 -0
  25. package/src/lib/utils/filter.ts +79 -0
  26. package/src/lib/utils/run-motion.test.ts +67 -0
  27. package/src/lib/utils/run-motion.ts +56 -0
  28. package/src/lib/utils/theme.svelte.test.ts +142 -0
  29. package/src/lib/utils/theme.svelte.ts +71 -0
  30. package/src/stories/DataGrid.stories.svelte +64 -1
  31. package/src/stories/FilterBar.stories.svelte +61 -0
  32. package/src/stories/Welcome.mdx +1 -1
  33. package/src/astro/CTABand.astro +0 -39
  34. package/src/astro/Hero.astro +0 -58
  35. 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
+ }