@runwisp/ui 1.0.0 → 1.1.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/components/AlertDialog.svelte +63 -25
- package/src/lib/components/Badge.svelte +51 -33
- 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/Tooltip.svelte +5 -2
- package/src/lib/components/dashboard/run-filters.test.ts +2 -2
- package/src/lib/components/dashboard/run-filters.ts +5 -5
- package/src/lib/components/dashboard/run-helpers.test.ts +1 -1
- package/src/lib/components/dashboard/run-helpers.ts +1 -1
- package/src/lib/components/filter-spec.ts +94 -0
- package/src/lib/index.ts +26 -1
- 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/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
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,5 @@ 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";
|
|
@@ -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,142 @@
|
|
|
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
|
+
|
|
6
|
+
interface BrowserStub {
|
|
7
|
+
media: { matches: boolean };
|
|
8
|
+
classList: Set<string>;
|
|
9
|
+
fireMediaChange: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stubBrowser(
|
|
13
|
+
options: {
|
|
14
|
+
matches?: boolean;
|
|
15
|
+
storage?: Record<string, string>;
|
|
16
|
+
throwOnStorage?: boolean;
|
|
17
|
+
} = {},
|
|
18
|
+
): BrowserStub {
|
|
19
|
+
const { matches = false, storage = {}, throwOnStorage = false } = options;
|
|
20
|
+
const listeners: (() => void)[] = [];
|
|
21
|
+
const media = { matches };
|
|
22
|
+
const classList = new Set<string>();
|
|
23
|
+
|
|
24
|
+
vi.stubGlobal("window", {});
|
|
25
|
+
vi.stubGlobal("matchMedia", () => ({
|
|
26
|
+
get matches() {
|
|
27
|
+
return media.matches;
|
|
28
|
+
},
|
|
29
|
+
addEventListener: (_event: string, cb: () => void) => {
|
|
30
|
+
listeners.push(cb);
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
vi.stubGlobal("localStorage", {
|
|
34
|
+
getItem: (key: string) => {
|
|
35
|
+
if (throwOnStorage) throw new Error("blocked");
|
|
36
|
+
return storage[key] ?? null;
|
|
37
|
+
},
|
|
38
|
+
setItem: (key: string, value: string) => {
|
|
39
|
+
if (throwOnStorage) throw new Error("blocked");
|
|
40
|
+
storage[key] = value;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
vi.stubGlobal("document", {
|
|
44
|
+
documentElement: {
|
|
45
|
+
classList: {
|
|
46
|
+
toggle: (cls: string, on: boolean) => {
|
|
47
|
+
if (on) classList.add(cls);
|
|
48
|
+
else classList.delete(cls);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
media,
|
|
56
|
+
classList,
|
|
57
|
+
fireMediaChange: () => {
|
|
58
|
+
listeners.forEach((cb) => {
|
|
59
|
+
cb();
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("themeStore", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
vi.resetModules();
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.unstubAllGlobals();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("degrades outside a browser", async () => {
|
|
74
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
75
|
+
expect(themeStore.preference).toBe("auto");
|
|
76
|
+
expect(themeStore.resolved).toBe("light");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("resolves auto to the OS preference", async () => {
|
|
80
|
+
stubBrowser({ matches: true });
|
|
81
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
82
|
+
expect(themeStore.resolved).toBe("dark");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reads a stored preference over auto", async () => {
|
|
86
|
+
stubBrowser({ storage: { "runwisp:theme": "dark" } });
|
|
87
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
88
|
+
expect(themeStore.preference).toBe("dark");
|
|
89
|
+
expect(themeStore.resolved).toBe("dark");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls back to the legacy storage key", async () => {
|
|
93
|
+
stubBrowser({ storage: { "runwisp-theme": "light" } });
|
|
94
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
95
|
+
expect(themeStore.preference).toBe("light");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("ignores an invalid stored value", async () => {
|
|
99
|
+
stubBrowser({ storage: { "runwisp:theme": "purple" } });
|
|
100
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
101
|
+
expect(themeStore.preference).toBe("auto");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("re-resolves live when the OS preference changes while in auto", async () => {
|
|
105
|
+
const env = stubBrowser({ matches: false });
|
|
106
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
107
|
+
expect(themeStore.resolved).toBe("light");
|
|
108
|
+
env.media.matches = true;
|
|
109
|
+
env.fireMediaChange();
|
|
110
|
+
expect(themeStore.resolved).toBe("dark");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ignores OS changes once a manual preference is set", async () => {
|
|
114
|
+
const env = stubBrowser({ matches: false });
|
|
115
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
116
|
+
themeStore.set("light");
|
|
117
|
+
env.media.matches = true;
|
|
118
|
+
env.fireMediaChange();
|
|
119
|
+
expect(themeStore.resolved).toBe("light");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("persists an explicit preference and toggles the dark class", async () => {
|
|
123
|
+
const env = stubBrowser();
|
|
124
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
125
|
+
themeStore.set("dark");
|
|
126
|
+
expect(themeStore.resolved).toBe("dark");
|
|
127
|
+
expect(env.classList.has("dark")).toBe(true);
|
|
128
|
+
expect(localStorage.getItem("runwisp:theme")).toBe("dark");
|
|
129
|
+
|
|
130
|
+
themeStore.set("light");
|
|
131
|
+
expect(env.classList.has("dark")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("still applies the preference for this page when storage write throws", async () => {
|
|
135
|
+
stubBrowser({ throwOnStorage: true });
|
|
136
|
+
const { themeStore } = await import("./theme.svelte.js");
|
|
137
|
+
expect(() => {
|
|
138
|
+
themeStore.set("dark");
|
|
139
|
+
}).not.toThrow();
|
|
140
|
+
expect(themeStore.resolved).toBe("dark");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
export type ThemePreference = "auto" | "light" | "dark";
|
|
5
|
+
export type ResolvedTheme = "light" | "dark";
|
|
6
|
+
|
|
7
|
+
// Shared by every RunWisp surface. Keep byte-for-byte in sync with the
|
|
8
|
+
// no-flash <head> scripts in each app's app.html / layout.
|
|
9
|
+
export const THEME_STORAGE_KEY = "runwisp:theme";
|
|
10
|
+
// The daemon dashboard used this key before the store moved here.
|
|
11
|
+
const LEGACY_STORAGE_KEY = "runwisp-theme";
|
|
12
|
+
|
|
13
|
+
function isPreference(value: string | null): value is ThemePreference {
|
|
14
|
+
return value === "auto" || value === "light" || value === "dark";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readStored(): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return localStorage.getItem(THEME_STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
20
|
+
} catch {
|
|
21
|
+
return null; // private mode / embedded contexts
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createThemeStore() {
|
|
26
|
+
const browser = typeof window !== "undefined";
|
|
27
|
+
let preference = $state<ThemePreference>("auto");
|
|
28
|
+
let resolved = $state<ResolvedTheme>("light");
|
|
29
|
+
|
|
30
|
+
const media = browser ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
|
|
31
|
+
|
|
32
|
+
function apply(): void {
|
|
33
|
+
if (!browser) return;
|
|
34
|
+
if (preference === "auto") {
|
|
35
|
+
resolved = media?.matches === true ? "dark" : "light";
|
|
36
|
+
} else {
|
|
37
|
+
resolved = preference;
|
|
38
|
+
}
|
|
39
|
+
document.documentElement.classList.toggle("dark", resolved === "dark");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (browser) {
|
|
43
|
+
const stored = readStored();
|
|
44
|
+
if (isPreference(stored)) preference = stored;
|
|
45
|
+
apply();
|
|
46
|
+
// Re-resolve live when the OS preference changes while in `auto`.
|
|
47
|
+
media?.addEventListener("change", () => {
|
|
48
|
+
if (preference === "auto") apply();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
get preference(): ThemePreference {
|
|
54
|
+
return preference;
|
|
55
|
+
},
|
|
56
|
+
get resolved(): ResolvedTheme {
|
|
57
|
+
return resolved;
|
|
58
|
+
},
|
|
59
|
+
set(pref: ThemePreference): void {
|
|
60
|
+
preference = pref;
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem(THEME_STORAGE_KEY, pref);
|
|
63
|
+
} catch {
|
|
64
|
+
// not persisted; still applied for this page
|
|
65
|
+
}
|
|
66
|
+
apply();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const themeStore = createThemeStore();
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import DataGrid from "$lib/components/DataGrid.svelte";
|
|
7
7
|
import StatusIndicator from "$lib/components/StatusIndicator.svelte";
|
|
8
8
|
import Dropdown from "$lib/components/Dropdown.svelte";
|
|
9
|
+
import FilterBar from "$lib/components/FilterBar.svelte";
|
|
10
|
+
import { seedValues } from "$lib/components/filter-spec.js";
|
|
11
|
+
import { applyFilters } from "$lib/utils/filter.js";
|
|
9
12
|
import { Play, Pause, Trash2, SquarePen } from "@lucide/svelte";
|
|
10
13
|
|
|
11
14
|
const { Story } = defineMeta({
|
|
@@ -14,8 +17,10 @@
|
|
|
14
17
|
tags: ["autodocs"],
|
|
15
18
|
});
|
|
16
19
|
|
|
20
|
+
const nameColumn = { key: "name", label: "Task Name", sortable: true };
|
|
21
|
+
|
|
17
22
|
const columns = [
|
|
18
|
-
|
|
23
|
+
nameColumn,
|
|
19
24
|
{ key: "schedule", label: "Schedule" },
|
|
20
25
|
{ key: "lastRun", label: "Last Run", sortable: true },
|
|
21
26
|
{ key: "status", label: "Status" },
|
|
@@ -72,6 +77,31 @@
|
|
|
72
77
|
{ divider: true },
|
|
73
78
|
{ label: "Delete", icon: Trash2, danger: true },
|
|
74
79
|
];
|
|
80
|
+
|
|
81
|
+
/** @type {import("$lib/components/filter-spec.js").FilterField[]} */
|
|
82
|
+
const filterFields = [
|
|
83
|
+
{ type: "search", key: "q", fields: ["name", "schedule"], placeholder: "Search tasks..." },
|
|
84
|
+
{
|
|
85
|
+
type: "select",
|
|
86
|
+
key: "status",
|
|
87
|
+
label: "Status",
|
|
88
|
+
primary: true,
|
|
89
|
+
options: [
|
|
90
|
+
{ value: "all", label: "All" },
|
|
91
|
+
{ value: "success", label: "Success" },
|
|
92
|
+
{ value: "failed", label: "Failed" },
|
|
93
|
+
{ value: "running", label: "Running" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<script>
|
|
100
|
+
let filterValues = $state(seedValues(filterFields));
|
|
101
|
+
/** @type {any[]} */
|
|
102
|
+
let selected = $state([]);
|
|
103
|
+
let clicked = $state("");
|
|
104
|
+
const filtered = $derived(applyFilters(tasks, filterFields, filterValues));
|
|
75
105
|
</script>
|
|
76
106
|
|
|
77
107
|
{#snippet statusCell(/** @type {any} */ row)}
|
|
@@ -115,6 +145,39 @@
|
|
|
115
145
|
|
|
116
146
|
<Story name="Paginated" args={{ columns, data: tasks, paginate: true, pageSize: 2 }} />
|
|
117
147
|
|
|
148
|
+
<Story name="Filters, Sort, Selection, Pagination" asChild>
|
|
149
|
+
<div class="flex flex-col gap-3">
|
|
150
|
+
<FilterBar fields={filterFields} bind:values={filterValues} />
|
|
151
|
+
<DataGrid
|
|
152
|
+
{columns}
|
|
153
|
+
data={filtered}
|
|
154
|
+
selectable
|
|
155
|
+
bind:selectedRows={selected}
|
|
156
|
+
sortKey="name"
|
|
157
|
+
sortDirection="asc"
|
|
158
|
+
paginate
|
|
159
|
+
pageSize={3}
|
|
160
|
+
pageSizeOptions={[3, 5, 10]}
|
|
161
|
+
onRowClick={(row) => (clicked = row.name)}
|
|
162
|
+
emptyMessage="No tasks match these filters"
|
|
163
|
+
/>
|
|
164
|
+
<p class="font-mono text-xs text-on-surface-muted">
|
|
165
|
+
selected: {selected.map((r) => r.name).join(", ") || "none"} · last row click: {clicked ||
|
|
166
|
+
"none"}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
</Story>
|
|
170
|
+
|
|
171
|
+
<Story name="Bare With Footer, No Header" asChild>
|
|
172
|
+
<div class="rounded-[4px] border border-outline">
|
|
173
|
+
<DataGrid columns={[nameColumn]} data={tasks} bare showHeader={false}>
|
|
174
|
+
{#snippet footer()}
|
|
175
|
+
<button class="font-mono text-xs text-primary hover:underline">Load more</button>
|
|
176
|
+
{/snippet}
|
|
177
|
+
</DataGrid>
|
|
178
|
+
</div>
|
|
179
|
+
</Story>
|
|
180
|
+
|
|
118
181
|
<Story name="Striped" args={{ columns, data: tasks, striped: true }} />
|
|
119
182
|
|
|
120
183
|
<Story name="Compact" args={{ columns, data: tasks, compact: true }} />
|