@runwisp/ui 0.15.0 → 0.16.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/lib/actions/focusTrap.test.ts +56 -0
- package/src/lib/actions/focusTrap.ts +115 -0
- package/src/lib/components/DataGrid.svelte +4 -2
- package/src/lib/components/Drawer.svelte +2 -0
- package/src/lib/components/Dropdown.svelte +2 -0
- package/src/lib/components/DurationInput.svelte +1 -1
- package/src/lib/components/FormField.svelte +1 -1
- package/src/lib/components/Input.svelte +1 -1
- package/src/lib/components/LogConsole.svelte +16 -0
- package/src/lib/components/Modal.svelte +2 -0
- package/src/lib/components/Popover.svelte +2 -0
- package/src/lib/components/Select.svelte +7 -1
- package/src/lib/components/dashboard/RunDetailPanel.svelte +56 -14
- package/src/lib/components/dashboard/RunFilterPopover.svelte +14 -2
- package/src/lib/components/dashboard/RunsList.svelte +2 -2
- package/src/lib/components/dashboard/run-filters.test.ts +21 -4
- package/src/lib/components/dashboard/run-filters.ts +16 -1
- package/src/lib/components/dashboard/run-helpers.test.ts +12 -12
- package/src/lib/components/dashboard/run-helpers.ts +10 -10
- package/src/lib/components/dashboard/status-config.ts +2 -2
- package/src/lib/components/data-grid-selection.test.ts +68 -0
- package/src/lib/components/data-grid-selection.ts +34 -0
- package/src/lib/theme-tokens.css +0 -2
- package/src/lib/components/FeatureCard.svelte +0 -35
- package/src/lib/components/FeatureGrid.svelte +0 -37
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runwisp/ui",
|
|
3
3
|
"description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.16.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@chromatic-com/storybook": "^5.2.1",
|
|
51
51
|
"@eslint/js": "^10.0.1",
|
|
52
|
-
"@runwisp/eslint-config": "0.
|
|
53
|
-
"@runwisp/typescript-config": "0.
|
|
52
|
+
"@runwisp/eslint-config": "0.16.0",
|
|
53
|
+
"@runwisp/typescript-config": "0.16.0",
|
|
54
54
|
"@storybook/addon-a11y": "^10.5.5",
|
|
55
55
|
"@storybook/addon-docs": "^10.5.5",
|
|
56
56
|
"@storybook/addon-svelte-csf": "^5.1.2",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
94
94
|
"@fontsource/tasa-orbiter": "^5.3.0",
|
|
95
95
|
"@lucide/svelte": "^1.28.0",
|
|
96
|
-
"@runwisp/common": "0.
|
|
96
|
+
"@runwisp/common": "0.16.0",
|
|
97
97
|
"@tanstack/svelte-virtual": "^3.13.35",
|
|
98
98
|
"ansi-to-html": "^0.7.2",
|
|
99
99
|
"cron-parser": "^5.7.0",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { resolveTrapFocusTarget } from "./focusTrap.js";
|
|
6
|
+
|
|
7
|
+
// resolveTrapFocusTarget is generic and identity-based on purpose: it never
|
|
8
|
+
// touches the DOM, so it's exercised here with plain sentinel objects
|
|
9
|
+
// instead of real elements (this repo's vitest env has no jsdom).
|
|
10
|
+
describe("resolveTrapFocusTarget", () => {
|
|
11
|
+
const first = { id: "first" };
|
|
12
|
+
const middle = { id: "middle" };
|
|
13
|
+
const last = { id: "last" };
|
|
14
|
+
const focusable = [first, middle, last];
|
|
15
|
+
|
|
16
|
+
it("wraps Tab from the last focusable element back to the first", () => {
|
|
17
|
+
const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, focusable, last);
|
|
18
|
+
expect(target).toBe(first);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("wraps Shift+Tab from the first focusable element back to the last", () => {
|
|
22
|
+
const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: true }, focusable, first);
|
|
23
|
+
expect(target).toBe(last);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does nothing when Tab is pressed from a middle element", () => {
|
|
27
|
+
const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, focusable, middle);
|
|
28
|
+
expect(target).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does nothing when Shift+Tab is pressed from a middle element", () => {
|
|
32
|
+
const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: true }, focusable, middle);
|
|
33
|
+
expect(target).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("ignores non-Tab keys", () => {
|
|
37
|
+
const target = resolveTrapFocusTarget({ key: "Escape", shiftKey: false }, focusable, last);
|
|
38
|
+
expect(target).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does nothing when there are no focusable elements", () => {
|
|
42
|
+
const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, [], null);
|
|
43
|
+
expect(target).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("wraps a single focusable element to itself in both directions", () => {
|
|
47
|
+
const solo = [first];
|
|
48
|
+
expect(resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, solo, first)).toBe(first);
|
|
49
|
+
expect(resolveTrapFocusTarget({ key: "Tab", shiftKey: true }, solo, first)).toBe(first);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does nothing when active is null and doesn't match first or last", () => {
|
|
53
|
+
const target = resolveTrapFocusTarget({ key: "Tab", shiftKey: false }, focusable, null);
|
|
54
|
+
expect(target).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
const FOCUSABLE_SELECTOR = [
|
|
5
|
+
"a[href]",
|
|
6
|
+
"button:not([disabled])",
|
|
7
|
+
"input:not([disabled])",
|
|
8
|
+
"select:not([disabled])",
|
|
9
|
+
"textarea:not([disabled])",
|
|
10
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
11
|
+
].join(",");
|
|
12
|
+
|
|
13
|
+
function isHTMLElement(value: unknown): value is HTMLElement {
|
|
14
|
+
return value instanceof HTMLElement;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** All focusable, visible descendants of `container`, in DOM (tab) order. */
|
|
18
|
+
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
|
19
|
+
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
20
|
+
(el) => el.offsetParent !== null || el === document.activeElement,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure decision function: given a keydown event, the currently focusable
|
|
26
|
+
* elements (in order), and whatever currently has focus, returns the element
|
|
27
|
+
* that Tab/Shift+Tab should wrap focus to in order to keep it trapped inside
|
|
28
|
+
* the set — or `null` if the key doesn't need handling.
|
|
29
|
+
*
|
|
30
|
+
* Framework- and DOM-free on purpose (elements are compared by identity only)
|
|
31
|
+
* so this can be unit tested without a browser or jsdom.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveTrapFocusTarget<T>(
|
|
34
|
+
event: Pick<KeyboardEvent, "key" | "shiftKey">,
|
|
35
|
+
focusable: readonly T[],
|
|
36
|
+
active: T | null,
|
|
37
|
+
): T | null {
|
|
38
|
+
if (event.key !== "Tab" || focusable.length === 0) return null;
|
|
39
|
+
const first = focusable[0];
|
|
40
|
+
const last = focusable[focusable.length - 1];
|
|
41
|
+
if (first === undefined || last === undefined) return null;
|
|
42
|
+
|
|
43
|
+
if (event.shiftKey && active === first) return last;
|
|
44
|
+
if (!event.shiftKey && active === last) return first;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TrapFocusOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Move focus to the first focusable descendant (or the container itself)
|
|
51
|
+
* as soon as the trap is created. Defaults to `true`; set to `false` when
|
|
52
|
+
* the caller already manages initial focus (e.g. a searchable combobox
|
|
53
|
+
* that focuses its own search input).
|
|
54
|
+
*/
|
|
55
|
+
autoFocus?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Svelte action that keeps keyboard focus inside `node` while it's mounted:
|
|
60
|
+
* Tab from the last focusable descendant wraps to the first (and Shift+Tab
|
|
61
|
+
* from the first wraps to the last), and focus is restored to whatever was
|
|
62
|
+
* focused beforehand once the trap is torn down — unless a click on some
|
|
63
|
+
* other real element already moved focus there, in which case that's left
|
|
64
|
+
* alone.
|
|
65
|
+
*/
|
|
66
|
+
export function trapFocus(node: HTMLElement, options: TrapFocusOptions = {}) {
|
|
67
|
+
const autoFocus = options.autoFocus ?? true;
|
|
68
|
+
const previouslyFocused = isHTMLElement(document.activeElement) ? document.activeElement : null;
|
|
69
|
+
|
|
70
|
+
if (autoFocus) {
|
|
71
|
+
// Deferred a tick: `node` may still be moved by a sibling `use:portal`
|
|
72
|
+
// action (appendChild-ing it to <body>) after this action runs, and
|
|
73
|
+
// relocating a focused element resets focus to <body>. Waiting a
|
|
74
|
+
// microtask lets any such move finish first, so the focus target
|
|
75
|
+
// ends up wherever the element actually settles.
|
|
76
|
+
queueMicrotask(() => {
|
|
77
|
+
const target = getFocusableElements(node)[0] ?? node;
|
|
78
|
+
if (target === node && !node.hasAttribute("tabindex")) {
|
|
79
|
+
node.tabIndex = -1;
|
|
80
|
+
}
|
|
81
|
+
target.focus();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
86
|
+
const active = isHTMLElement(document.activeElement) ? document.activeElement : null;
|
|
87
|
+
const target = resolveTrapFocusTarget(e, getFocusableElements(node), active);
|
|
88
|
+
if (target) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
target.focus();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
node.addEventListener("keydown", handleKeydown);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
destroy() {
|
|
98
|
+
node.removeEventListener("keydown", handleKeydown);
|
|
99
|
+
// By the time an action's destroy() runs, `node` (and whatever
|
|
100
|
+
// inside it held focus) has already been detached — removing a
|
|
101
|
+
// focused element resets focus to <body> as a browser default, so
|
|
102
|
+
// that can't be told apart from an explicit close (Escape, item
|
|
103
|
+
// activation) by checking `node.contains(activeElement)` here.
|
|
104
|
+
// What *can* still be told apart: a deliberate outside click
|
|
105
|
+
// already moved focus to its own (still-attached) target before
|
|
106
|
+
// this runs, so activeElement is a real element other than
|
|
107
|
+
// <body>. Only restore focus when that's not the case.
|
|
108
|
+
const active = document.activeElement;
|
|
109
|
+
const focusWasReset = !isHTMLElement(active) || active === document.body;
|
|
110
|
+
if (focusWasReset && previouslyFocused && document.contains(previouslyFocused)) {
|
|
111
|
+
previouslyFocused.focus();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
import Input from "./Input.svelte";
|
|
55
55
|
import Pagination from "./Pagination.svelte";
|
|
56
56
|
import Fuse from "fuse.js";
|
|
57
|
+
import { selectionState } from "./data-grid-selection.js";
|
|
57
58
|
|
|
58
59
|
let {
|
|
59
60
|
columns,
|
|
@@ -162,8 +163,9 @@
|
|
|
162
163
|
return filteredData.slice(start, start + pageSize);
|
|
163
164
|
});
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
let
|
|
166
|
+
const selection = $derived(selectionState(pagedData, selectedRows, rowKey));
|
|
167
|
+
let allSelected = $derived(selection.allSelected);
|
|
168
|
+
let someSelected = $derived(selection.someSelected);
|
|
167
169
|
</script>
|
|
168
170
|
|
|
169
171
|
<div
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
import { X } from "@lucide/svelte";
|
|
7
7
|
import { portal } from "../actions/portal.js";
|
|
8
|
+
import { trapFocus } from "../actions/focusTrap.js";
|
|
8
9
|
import { fly } from "svelte/transition";
|
|
9
10
|
|
|
10
11
|
type Side = "left" | "right" | "top" | "bottom";
|
|
@@ -98,6 +99,7 @@
|
|
|
98
99
|
{/if}
|
|
99
100
|
|
|
100
101
|
<div
|
|
102
|
+
use:trapFocus
|
|
101
103
|
class="
|
|
102
104
|
absolute z-10 flex flex-col
|
|
103
105
|
border border-outline bg-surface-overlay shadow-lg
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
let transformOrigin = $state("top right");
|
|
46
46
|
|
|
47
47
|
import { portal } from "../actions/portal.js";
|
|
48
|
+
import { trapFocus } from "../actions/focusTrap.js";
|
|
48
49
|
|
|
49
50
|
async function setupFloating() {
|
|
50
51
|
if (!triggerEl || !menuEl) return;
|
|
@@ -133,6 +134,7 @@
|
|
|
133
134
|
{#if open}
|
|
134
135
|
<div
|
|
135
136
|
use:portal
|
|
137
|
+
use:trapFocus
|
|
136
138
|
bind:this={menuEl}
|
|
137
139
|
role="menu"
|
|
138
140
|
class="
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
// "muted" (default) renders the terminus in the gutter grey; "warn"
|
|
32
32
|
// tints it amber for runs that ended by intervention rather than naturally.
|
|
33
33
|
endTone?: "muted" | "warn";
|
|
34
|
+
// Message from the most recent fetchLogs failure (session expiry,
|
|
35
|
+
// daemon restart mid-request, network blip, ...). Rendered as a small
|
|
36
|
+
// banner above the log surface so a fetch failure never looks like a
|
|
37
|
+
// run that simply produced no output. Null/undefined renders nothing;
|
|
38
|
+
// the caller clears it once a subsequent fetch succeeds.
|
|
39
|
+
error?: string | null;
|
|
34
40
|
// Wrap long lines instead of horizontally scrolling them. When on, each
|
|
35
41
|
// rendered row's height becomes a multiple of `lineHeight` (one per
|
|
36
42
|
// wrapped visual row), so the virtualizer switches from the fixed-height
|
|
@@ -48,6 +54,7 @@
|
|
|
48
54
|
fetchLineHistory,
|
|
49
55
|
endLabel = "end of output",
|
|
50
56
|
endTone = "muted",
|
|
57
|
+
error = null,
|
|
51
58
|
wrap = $bindable(false),
|
|
52
59
|
}: Props = $props();
|
|
53
60
|
|
|
@@ -557,6 +564,15 @@
|
|
|
557
564
|
>{RULER_SAMPLE}</span
|
|
558
565
|
>
|
|
559
566
|
|
|
567
|
+
{#if error}
|
|
568
|
+
<div
|
|
569
|
+
class="flex shrink-0 items-center gap-2 border-b border-[var(--rw-con-gutter)] px-3.5 py-2 text-[11.5px]"
|
|
570
|
+
style="color: var(--rw-term-bad); background-color: color-mix(in srgb, var(--rw-term-bad) 12%, transparent);"
|
|
571
|
+
>
|
|
572
|
+
Failed to load log: {error}
|
|
573
|
+
</div>
|
|
574
|
+
{/if}
|
|
575
|
+
|
|
560
576
|
<div bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
|
|
561
577
|
<div
|
|
562
578
|
class="relative"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
import { portal } from "../actions/portal.js";
|
|
24
|
+
import { trapFocus } from "../actions/focusTrap.js";
|
|
24
25
|
|
|
25
26
|
let {
|
|
26
27
|
open = $bindable(false),
|
|
@@ -79,6 +80,7 @@
|
|
|
79
80
|
{/if}
|
|
80
81
|
|
|
81
82
|
<div
|
|
83
|
+
use:trapFocus
|
|
82
84
|
class="
|
|
83
85
|
relative z-10 w-full {sizeClasses[size]}
|
|
84
86
|
flex max-h-[90vh] flex-col
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { computePosition, autoUpdate, flip, shift, offset } from "@floating-ui/dom";
|
|
9
9
|
import { fade } from "svelte/transition";
|
|
10
10
|
import { portal } from "../actions/portal.js";
|
|
11
|
+
import { trapFocus } from "../actions/focusTrap.js";
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
open?: boolean;
|
|
@@ -144,6 +145,7 @@
|
|
|
144
145
|
{/if}
|
|
145
146
|
<div
|
|
146
147
|
use:portal
|
|
148
|
+
use:trapFocus
|
|
147
149
|
bind:this={contentEl}
|
|
148
150
|
class="
|
|
149
151
|
z-[9999] max-h-[80vh] overflow-y-auto
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
import { portal } from "../actions/portal.js";
|
|
111
|
+
import { trapFocus } from "../actions/focusTrap.js";
|
|
111
112
|
|
|
112
113
|
async function setupFloating() {
|
|
113
114
|
if (!triggerEl || !menuEl) return;
|
|
@@ -299,7 +300,12 @@
|
|
|
299
300
|
{/if}
|
|
300
301
|
|
|
301
302
|
{#if isOpen}
|
|
302
|
-
<div
|
|
303
|
+
<div
|
|
304
|
+
use:portal
|
|
305
|
+
use:trapFocus={{ autoFocus: false }}
|
|
306
|
+
bind:this={menuEl}
|
|
307
|
+
class="z-[9999] min-w-[200px]"
|
|
308
|
+
>
|
|
303
309
|
<div
|
|
304
310
|
class="
|
|
305
311
|
overflow-hidden rounded-[4px] border border-outline bg-surface-overlay shadow-md
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import Tooltip from "../Tooltip.svelte";
|
|
31
31
|
import { portal } from "../../actions/portal.js";
|
|
32
32
|
import type { Run } from "./types.js";
|
|
33
|
-
import type
|
|
33
|
+
import { isLogEvent, type LogEvent, type LogSlice } from "../../log-console/types.js";
|
|
34
34
|
import { formatClockTime, formatCalendarDate } from "../../utils/format.js";
|
|
35
35
|
import { formatShortId } from "../../utils/id.js";
|
|
36
36
|
import { TickingNow } from "../../utils/ticking-now.svelte.js";
|
|
@@ -134,10 +134,41 @@
|
|
|
134
134
|
|
|
135
135
|
let logConsole = $state<{ onStream: (event: LogEvent) => void } | null>(null);
|
|
136
136
|
|
|
137
|
+
// Message from the most recent seed-fetch failure (session expiry, daemon
|
|
138
|
+
// restart mid-request, ...), surfaced to LogConsole instead of letting a
|
|
139
|
+
// failed fetch render as an indistinguishable blank "no output" pane.
|
|
140
|
+
let logFetchError = $state<string | null>(null);
|
|
141
|
+
|
|
137
142
|
// Derive a stable scalar so the effect only re-runs when the id actually
|
|
138
143
|
// changes, not on every run object reference swap from SSE array updates.
|
|
139
144
|
let runId = $derived(run?.id);
|
|
140
145
|
|
|
146
|
+
// Fetches the batched tail page a run's console opens on (see the effect
|
|
147
|
+
// below) and paints it into the console. Resolves the line the live
|
|
148
|
+
// stream should resume from, or null when there's nothing left to stream
|
|
149
|
+
// (the run already ended, or the caller cancelled while we were waiting).
|
|
150
|
+
// A failed fetch still falls back to an SSE tail backfill from scratch —
|
|
151
|
+
// it does not stop the console from live-tailing — but records the
|
|
152
|
+
// failure so LogConsole can show it instead of looking like a quiet run.
|
|
153
|
+
async function seedConsoleTail(
|
|
154
|
+
id: string,
|
|
155
|
+
isCancelled: () => boolean,
|
|
156
|
+
): Promise<{ fromLine: number } | null> {
|
|
157
|
+
try {
|
|
158
|
+
const seed = await fetchLogs(id, -TAIL_LINES, -1);
|
|
159
|
+
if (isCancelled()) return null;
|
|
160
|
+
if (!isLogEvent(seed)) return { fromLine: -TAIL_LINES };
|
|
161
|
+
if (logConsole) logConsole.onStream(seed);
|
|
162
|
+
return seed.finished // ended run: nothing live to follow
|
|
163
|
+
? null
|
|
164
|
+
: { fromLine: seed.sizeLines > 0 ? seed.sizeLines : -TAIL_LINES };
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (isCancelled()) return null;
|
|
167
|
+
logFetchError = err instanceof Error ? err.message : "Failed to load logs";
|
|
168
|
+
return { fromLine: -TAIL_LINES };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
141
172
|
$effect(() => {
|
|
142
173
|
const id = runId;
|
|
143
174
|
if (!id) return;
|
|
@@ -145,23 +176,33 @@
|
|
|
145
176
|
const stream = streamLogs;
|
|
146
177
|
if (!stream) return;
|
|
147
178
|
|
|
179
|
+
logFetchError = null;
|
|
180
|
+
|
|
148
181
|
let cleanup: (() => void) | undefined;
|
|
182
|
+
let cancelled = false;
|
|
149
183
|
|
|
150
184
|
untrack(() => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
void (async () => {
|
|
186
|
+
// Paint the tail in one batched page so the console opens
|
|
187
|
+
// already at the newest output, instead of replaying 1000
|
|
188
|
+
// backfill lines over SSE and visibly scrolling down to reach
|
|
189
|
+
// the end. The live stream then picks up after the seeded tail
|
|
190
|
+
// (its disk backfill from that anchor closes the fetch↔stream
|
|
191
|
+
// race, and the daemon dedupes anything already shown).
|
|
192
|
+
const seeded = await seedConsoleTail(id, () => cancelled);
|
|
193
|
+
if (cancelled || !seeded) return;
|
|
194
|
+
cleanup = stream(
|
|
195
|
+
id,
|
|
196
|
+
(event: LogEvent) => {
|
|
197
|
+
if (logConsole) logConsole.onStream(event);
|
|
198
|
+
},
|
|
199
|
+
{ fromLine: seeded.fromLine },
|
|
200
|
+
);
|
|
201
|
+
})();
|
|
162
202
|
});
|
|
163
203
|
|
|
164
204
|
return () => {
|
|
205
|
+
cancelled = true;
|
|
165
206
|
if (cleanup) cleanup();
|
|
166
207
|
};
|
|
167
208
|
});
|
|
@@ -288,7 +329,7 @@
|
|
|
288
329
|
run.status === "running" ? durationTicker.now.getTime() : undefined,
|
|
289
330
|
)}
|
|
290
331
|
{@const startDelay = runStartDelay(run)}
|
|
291
|
-
{@const startedAt = run.
|
|
332
|
+
{@const startedAt = run.startedAt ?? run.createdAt}
|
|
292
333
|
{@const retry = runRetryLabel(run)}
|
|
293
334
|
{@const paramEntries = run.params ? Object.entries(run.params) : []}
|
|
294
335
|
{@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
|
|
@@ -403,7 +444,7 @@
|
|
|
403
444
|
repeat it. The line wraps at narrow widths instead of
|
|
404
445
|
dropping the date and hiding it behind a hover. -->
|
|
405
446
|
<span data-testid="run-started" class="text-on-surface-muted"
|
|
406
|
-
>{run.
|
|
447
|
+
>{run.startedAt ? formatClockTime(run.startedAt) : "—"}</span
|
|
407
448
|
>
|
|
408
449
|
<span class="text-outline-hover" aria-hidden="true">·</span>
|
|
409
450
|
<span>{formatCalendarDate(startedAt)}</span>
|
|
@@ -744,6 +785,7 @@
|
|
|
744
785
|
{highlightLine}
|
|
745
786
|
{endLabel}
|
|
746
787
|
{endTone}
|
|
788
|
+
error={logFetchError}
|
|
747
789
|
/>
|
|
748
790
|
{/key}
|
|
749
791
|
</div>
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
dayStartIso,
|
|
41
41
|
dayEndIso,
|
|
42
42
|
isExitCodeExprValid,
|
|
43
|
+
exitCodePropChanged,
|
|
43
44
|
type RunsListFilters,
|
|
44
45
|
} from "./run-filters.js";
|
|
45
46
|
|
|
@@ -122,9 +123,20 @@
|
|
|
122
123
|
|
|
123
124
|
// Exit code: a free-form expression (`137`, `>100`, `>100 <150`) normalized
|
|
124
125
|
// to an inclusive range at the wire. Edited in a local buffer for smooth
|
|
125
|
-
// typing and committed on change
|
|
126
|
-
//
|
|
126
|
+
// typing and committed on change. `RunFilterPopover` itself is long-lived
|
|
127
|
+
// (its trigger button stays mounted while the popover is closed), so the
|
|
128
|
+
// buffer does NOT re-seed on its own — it must explicitly resync whenever
|
|
129
|
+
// `filters.exitCode` changes from outside (a chip removal, "clear
|
|
130
|
+
// filters"), without clobbering in-progress typing when some unrelated
|
|
131
|
+
// filter dimension changes instead.
|
|
127
132
|
let exitCodeInput = $state(filters.exitCode ?? "");
|
|
133
|
+
let lastExitCode = filters.exitCode;
|
|
134
|
+
$effect(() => {
|
|
135
|
+
if (exitCodePropChanged(lastExitCode, filters.exitCode)) {
|
|
136
|
+
exitCodeInput = filters.exitCode ?? "";
|
|
137
|
+
}
|
|
138
|
+
lastExitCode = filters.exitCode;
|
|
139
|
+
});
|
|
128
140
|
const exitCodeValid = $derived(isExitCodeExprValid(exitCodeInput));
|
|
129
141
|
|
|
130
142
|
function commitExitCode() {
|
|
@@ -626,7 +626,7 @@
|
|
|
626
626
|
checked={isRowSelected(run.id)}
|
|
627
627
|
onchange={() => toggleRow(run.id)}
|
|
628
628
|
onclick={(e) => e.stopPropagation()}
|
|
629
|
-
aria-label={`Select run from ${formatDateTime(run.
|
|
629
|
+
aria-label={`Select run from ${formatDateTime(run.startedAt ?? run.createdAt)}`}
|
|
630
630
|
class="size-3.5 cursor-pointer rounded border-outline accent-primary opacity-0 {selectionActive
|
|
631
631
|
? 'opacity-100'
|
|
632
632
|
: 'group-hover/row:opacity-100'}"
|
|
@@ -639,7 +639,7 @@
|
|
|
639
639
|
{@const config = getRunStatusConfig(dstatus)}
|
|
640
640
|
{@const running = run.status === "running"}
|
|
641
641
|
{@const spine = config.dot.replace(" animate-pulse", "")}
|
|
642
|
-
{@const startedAt = run.
|
|
642
|
+
{@const startedAt = run.startedAt ?? run.createdAt}
|
|
643
643
|
{@const retry = runRetryLabel(run)}
|
|
644
644
|
{@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
|
|
645
645
|
<button
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
exitCodeRangeActive,
|
|
29
29
|
isExitCodeExprValid,
|
|
30
30
|
exitCodeChipLabel,
|
|
31
|
+
exitCodePropChanged,
|
|
31
32
|
type StatusBucket,
|
|
32
33
|
type RunsListFilters,
|
|
33
34
|
} from "./run-filters.js";
|
|
@@ -67,7 +68,7 @@ describe("isNeedsAttention", () => {
|
|
|
67
68
|
|
|
68
69
|
it("is false for a strict subset or a different set", () => {
|
|
69
70
|
expect(isNeedsAttention(["failed"])).toBe(false);
|
|
70
|
-
expect(isNeedsAttention(["
|
|
71
|
+
expect(isNeedsAttention(["succeeded"])).toBe(false);
|
|
71
72
|
expect(isNeedsAttention([])).toBe(false);
|
|
72
73
|
});
|
|
73
74
|
});
|
|
@@ -158,7 +159,7 @@ describe("statusChipLabel", () => {
|
|
|
158
159
|
// The Failed bucket is the needs-attention set.
|
|
159
160
|
expect(statusChipLabel([...NEEDS_ATTENTION_STATUSES])).toBe("Failed");
|
|
160
161
|
expect(statusChipLabel(["pending", "running"])).toBe("Running");
|
|
161
|
-
expect(statusChipLabel(["
|
|
162
|
+
expect(statusChipLabel(["succeeded"])).toBe("Succeeded");
|
|
162
163
|
});
|
|
163
164
|
|
|
164
165
|
it("humanizes a single status that is not a whole bucket", () => {
|
|
@@ -200,9 +201,9 @@ describe("bucketState / toggleBucket", () => {
|
|
|
200
201
|
});
|
|
201
202
|
|
|
202
203
|
it("clears a fully-selected bucket without touching other statuses", () => {
|
|
203
|
-
const f = base({ statuses: [...failed.statuses, "
|
|
204
|
+
const f = base({ statuses: [...failed.statuses, "succeeded"] });
|
|
204
205
|
const cleared = toggleBucket(f, failed);
|
|
205
|
-
expect(cleared.statuses).toEqual(["
|
|
206
|
+
expect(cleared.statuses).toEqual(["succeeded"]);
|
|
206
207
|
});
|
|
207
208
|
});
|
|
208
209
|
|
|
@@ -295,3 +296,19 @@ describe("exit-code expression", () => {
|
|
|
295
296
|
expect(exitCodeChipLabel(" 137 ")).toBe("Exit 137");
|
|
296
297
|
});
|
|
297
298
|
});
|
|
299
|
+
|
|
300
|
+
describe("exitCodePropChanged", () => {
|
|
301
|
+
it("is false when the committed value is unchanged (unrelated filters update)", () => {
|
|
302
|
+
expect(exitCodePropChanged("137", "137")).toBe(false);
|
|
303
|
+
expect(exitCodePropChanged(undefined, undefined)).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("is true when the exit-code filter was cleared externally (chip removed)", () => {
|
|
307
|
+
expect(exitCodePropChanged("137", undefined)).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("is true when the exit-code filter was set to a new committed value", () => {
|
|
311
|
+
expect(exitCodePropChanged(undefined, "200")).toBe(true);
|
|
312
|
+
expect(exitCodePropChanged("137", "200")).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -77,7 +77,7 @@ export interface StatusBucket {
|
|
|
77
77
|
|
|
78
78
|
export const STATUS_BUCKETS: readonly StatusBucket[] = [
|
|
79
79
|
{ key: "running", label: "Running", dot: "bg-info-surface", statuses: ["pending", "running"] },
|
|
80
|
-
{ key: "succeeded", label: "Succeeded", dot: "bg-success-surface", statuses: ["
|
|
80
|
+
{ key: "succeeded", label: "Succeeded", dot: "bg-success-surface", statuses: ["succeeded"] },
|
|
81
81
|
{
|
|
82
82
|
key: "failed",
|
|
83
83
|
label: "Failed",
|
|
@@ -382,3 +382,18 @@ export function isExitCodeExprValid(expr: string): boolean {
|
|
|
382
382
|
export function exitCodeChipLabel(expr: string | undefined): string {
|
|
383
383
|
return `Exit ${(expr ?? "").trim()}`;
|
|
384
384
|
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Whether the popover's exit-code text buffer must resync to the `filters`
|
|
388
|
+
* prop. True only when the committed `exitCode` value actually moved since
|
|
389
|
+
* the buffer last matched it — e.g. the "Exit code" chip was removed, or
|
|
390
|
+
* "clear filters" ran elsewhere. An unrelated filters update (any other
|
|
391
|
+
* dimension) leaves `exitCode` untouched and must not clobber in-progress
|
|
392
|
+
* typing in the still-open popover.
|
|
393
|
+
*/
|
|
394
|
+
export function exitCodePropChanged(
|
|
395
|
+
previous: string | undefined,
|
|
396
|
+
next: string | undefined,
|
|
397
|
+
): boolean {
|
|
398
|
+
return previous !== next;
|
|
399
|
+
}
|
|
@@ -20,7 +20,7 @@ describe("runVerdict", () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it("phrases a timed outcome so a duration reads after it", () => {
|
|
23
|
-
expect(runVerdict("
|
|
23
|
+
expect(runVerdict("succeeded")).toEqual({ verb: "succeeded in", timed: true });
|
|
24
24
|
expect(runVerdict("failed")).toEqual({ verb: "failed after", timed: true });
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -36,20 +36,20 @@ describe("runVerdict", () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
describe("runDuration", () => {
|
|
39
|
-
it("returns undefined when
|
|
39
|
+
it("returns undefined when startedAt is not set", () => {
|
|
40
40
|
expect(runDuration({})).toBeUndefined();
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
it("returns formatted duration when both
|
|
43
|
+
it("returns formatted duration when both startedAt and endedAt are set", () => {
|
|
44
44
|
const start = "2024-06-15T12:00:00.000Z";
|
|
45
45
|
const end = "2024-06-15T12:00:05.000Z";
|
|
46
|
-
expect(runDuration({
|
|
46
|
+
expect(runDuration({ startedAt: start, endedAt: end })).toBe("5s");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it("returns ms duration for sub-second runs", () => {
|
|
50
50
|
const start = "2024-06-15T12:00:00.000Z";
|
|
51
51
|
const end = "2024-06-15T12:00:00.500Z";
|
|
52
|
-
expect(runDuration({
|
|
52
|
+
expect(runDuration({ startedAt: start, endedAt: end })).toBe("500ms");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
describe("with fake clock", () => {
|
|
@@ -61,27 +61,27 @@ describe("runDuration", () => {
|
|
|
61
61
|
vi.useRealTimers();
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it("uses current time when
|
|
65
|
-
expect(runDuration({
|
|
64
|
+
it("uses current time when endedAt is not set (run still in progress)", () => {
|
|
65
|
+
expect(runDuration({ startedAt: "2024-06-15T12:00:00.000Z" })).toBe("2s");
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
it("counts against an injected now for an in-progress run", () => {
|
|
70
70
|
const start = "2024-06-15T12:00:00.000Z";
|
|
71
71
|
const now = new Date("2024-06-15T12:00:07.000Z").getTime();
|
|
72
|
-
expect(runDuration({
|
|
72
|
+
expect(runDuration({ startedAt: start }, now)).toBe("7s");
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
it("ignores the injected now once the run has ended", () => {
|
|
76
76
|
const start = "2024-06-15T12:00:00.000Z";
|
|
77
77
|
const end = "2024-06-15T12:00:05.000Z";
|
|
78
78
|
const now = new Date("2024-06-15T12:01:00.000Z").getTime();
|
|
79
|
-
expect(runDuration({
|
|
79
|
+
expect(runDuration({ startedAt: start, endedAt: end }, now)).toBe("5s");
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
describe("runStartDelay", () => {
|
|
84
|
-
it("returns undefined when
|
|
84
|
+
it("returns undefined when startedAt is not set", () => {
|
|
85
85
|
expect(runStartDelay({ createdAt: "2024-06-15T12:00:00.000Z" })).toBeUndefined();
|
|
86
86
|
});
|
|
87
87
|
|
|
@@ -89,7 +89,7 @@ describe("runStartDelay", () => {
|
|
|
89
89
|
expect(
|
|
90
90
|
runStartDelay({
|
|
91
91
|
createdAt: "2024-06-15T12:00:00.000Z",
|
|
92
|
-
|
|
92
|
+
startedAt: "2024-06-15T12:00:00.300Z",
|
|
93
93
|
}),
|
|
94
94
|
).toBeUndefined();
|
|
95
95
|
});
|
|
@@ -98,7 +98,7 @@ describe("runStartDelay", () => {
|
|
|
98
98
|
expect(
|
|
99
99
|
runStartDelay({
|
|
100
100
|
createdAt: "2024-06-15T03:00:00.000Z",
|
|
101
|
-
|
|
101
|
+
startedAt: "2024-06-15T03:07:12.000Z",
|
|
102
102
|
}),
|
|
103
103
|
).toBe("7m 12s");
|
|
104
104
|
});
|
|
@@ -21,7 +21,7 @@ export interface RunVerdict {
|
|
|
21
21
|
* separate duration readout for the same glance.
|
|
22
22
|
*/
|
|
23
23
|
const RUN_VERDICTS: Record<RunStatus, RunVerdict> = {
|
|
24
|
-
|
|
24
|
+
succeeded: { verb: "succeeded in", timed: true },
|
|
25
25
|
failed: { verb: "failed after", timed: true },
|
|
26
26
|
crashed: { verb: "crashed after", timed: true },
|
|
27
27
|
timeout: { verb: "timed out after", timed: true },
|
|
@@ -43,26 +43,26 @@ export function runVerdict(status: RunStatus): RunVerdict {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function runDuration(
|
|
46
|
-
run: Pick<Run, "
|
|
46
|
+
run: Pick<Run, "startedAt" | "endedAt">,
|
|
47
47
|
now: number = Date.now(),
|
|
48
48
|
): string | undefined {
|
|
49
|
-
if (!run.
|
|
50
|
-
const start = new Date(run.
|
|
51
|
-
const end = run.
|
|
49
|
+
if (!run.startedAt) return undefined;
|
|
50
|
+
const start = new Date(run.startedAt).getTime();
|
|
51
|
+
const end = run.endedAt ? new Date(run.endedAt).getTime() : now;
|
|
52
52
|
return formatDuration(end - start);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Gap between when a run was scheduled (`createdAt`, the cron tick) and when
|
|
57
|
-
* it actually started (`
|
|
57
|
+
* it actually started (`startedAt`), formatted — or undefined when the two are
|
|
58
58
|
* within a second of each other. This is the visible face of `jitter`: a
|
|
59
59
|
* jittered run is created at its tick but starts later inside the window, and
|
|
60
60
|
* this is by how much. It also surfaces queue-wait, since a queued run is
|
|
61
61
|
* created when it joins the line and started when the line clears.
|
|
62
62
|
*/
|
|
63
|
-
export function runStartDelay(run: Pick<Run, "createdAt" | "
|
|
64
|
-
if (!run.
|
|
65
|
-
const delay = new Date(run.
|
|
63
|
+
export function runStartDelay(run: Pick<Run, "createdAt" | "startedAt">): string | undefined {
|
|
64
|
+
if (!run.startedAt) return undefined;
|
|
65
|
+
const delay = new Date(run.startedAt).getTime() - new Date(run.createdAt).getTime();
|
|
66
66
|
if (delay < 1000) return undefined;
|
|
67
67
|
return formatDuration(delay);
|
|
68
68
|
}
|
|
@@ -106,7 +106,7 @@ export function runRetryLabel(run: Pick<Run, "retryAttempt" | "retryOfRunId">):
|
|
|
106
106
|
/**
|
|
107
107
|
* Monotonic position of a run's status in its lifecycle. Used to reject stale
|
|
108
108
|
* updates (e.g. a `pending` HTTP response arriving after an SSE already
|
|
109
|
-
* advanced the row to `
|
|
109
|
+
* advanced the row to `succeeded`). Higher = further along.
|
|
110
110
|
*/
|
|
111
111
|
export function runPhaseOrder(status: string): number {
|
|
112
112
|
if (status === "pending") return 0;
|
|
@@ -38,7 +38,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
38
38
|
badge: "bg-info-soft text-info-soft-text",
|
|
39
39
|
description: "This run is executing right now.",
|
|
40
40
|
},
|
|
41
|
-
|
|
41
|
+
succeeded: {
|
|
42
42
|
icon: CircleCheck,
|
|
43
43
|
color: "text-success-surface",
|
|
44
44
|
bg: "bg-success-soft",
|
|
@@ -111,7 +111,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
111
111
|
border: "border-warning-soft",
|
|
112
112
|
dot: "bg-warning-surface",
|
|
113
113
|
badge: "bg-warning-soft text-warning-soft-text",
|
|
114
|
-
description: "Skipped because the task's queue was already at
|
|
114
|
+
description: "Skipped because the task's queue was already at max_queued.",
|
|
115
115
|
},
|
|
116
116
|
dst_skipped: {
|
|
117
117
|
icon: SkipForward,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { selectionState } from "./data-grid-selection.js";
|
|
6
|
+
|
|
7
|
+
interface Row {
|
|
8
|
+
id: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const rows = (...ids: string[]): Row[] => ids.map((id) => ({ id }));
|
|
12
|
+
|
|
13
|
+
describe("selectionState", () => {
|
|
14
|
+
it("is unchecked when nothing is selected", () => {
|
|
15
|
+
expect(selectionState(rows("a", "b", "c"), [], "id")).toEqual({
|
|
16
|
+
allSelected: false,
|
|
17
|
+
someSelected: false,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is checked when every paged row is selected", () => {
|
|
22
|
+
const paged = rows("a", "b", "c");
|
|
23
|
+
expect(selectionState(paged, paged, "id")).toEqual({
|
|
24
|
+
allSelected: true,
|
|
25
|
+
someSelected: false,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("is indeterminate when some but not all paged rows are selected", () => {
|
|
30
|
+
const paged = rows("a", "b", "c");
|
|
31
|
+
expect(selectionState(paged, rows("b"), "id")).toEqual({
|
|
32
|
+
allSelected: false,
|
|
33
|
+
someSelected: true,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("is NOT checked when selectedRows matches the paged count but is a different set of rows", () => {
|
|
38
|
+
// Regression: page 1 selected [a, b, c]; the grid then paged/filtered
|
|
39
|
+
// to a different page showing [d, e, f]. A count comparison
|
|
40
|
+
// (selectedRows.length === pagedRows.length) would wrongly report
|
|
41
|
+
// "all selected" here even though none of the visible rows are
|
|
42
|
+
// members of the selection.
|
|
43
|
+
const paged = rows("d", "e", "f");
|
|
44
|
+
const selected = rows("a", "b", "c");
|
|
45
|
+
expect(selectionState(paged, selected, "id")).toEqual({
|
|
46
|
+
allSelected: false,
|
|
47
|
+
someSelected: false,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("is indeterminate, not checked, when the overlap coincidentally matches the paged count minus one", () => {
|
|
52
|
+
const paged = rows("a", "b", "c");
|
|
53
|
+
// Carried-over selection from another page plus one row that is
|
|
54
|
+
// actually on this page.
|
|
55
|
+
const selected = rows("x", "y", "b");
|
|
56
|
+
expect(selectionState(paged, selected, "id")).toEqual({
|
|
57
|
+
allSelected: false,
|
|
58
|
+
someSelected: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("is unchecked when there are no paged rows, regardless of selection", () => {
|
|
63
|
+
expect(selectionState([], rows("a"), "id")).toEqual({
|
|
64
|
+
allSelected: false,
|
|
65
|
+
someSelected: false,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: PoppyCake, s.r.o.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// DataGrid's header checkbox (checked / indeterminate / unchecked) must
|
|
5
|
+
// reflect which rows on the current page are ACTUALLY selected, not merely
|
|
6
|
+
// how many rows are selected. A bare count comparison is wrong the moment
|
|
7
|
+
// `selectedRows` holds a different set of rows than `pagedRows` but happens
|
|
8
|
+
// to be the same size — e.g. rows selected on a page the grid has since
|
|
9
|
+
// paged/filtered away from. Extracted so the membership check is unit
|
|
10
|
+
// testable without a component-render harness.
|
|
11
|
+
|
|
12
|
+
export interface SelectionState {
|
|
13
|
+
allSelected: boolean;
|
|
14
|
+
someSelected: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Header checkbox state for `pagedRows`: fully checked only when every row
|
|
19
|
+
* currently on the page is selected, indeterminate when some (but not all)
|
|
20
|
+
* of them are. Matches rows by `rowKey`, not by array position or count.
|
|
21
|
+
*/
|
|
22
|
+
export function selectionState<T>(
|
|
23
|
+
pagedRows: readonly T[],
|
|
24
|
+
selectedRows: readonly T[],
|
|
25
|
+
rowKey: keyof T,
|
|
26
|
+
): SelectionState {
|
|
27
|
+
if (pagedRows.length === 0) return { allSelected: false, someSelected: false };
|
|
28
|
+
const selectedKeys = new Set(selectedRows.map((row) => row[rowKey]));
|
|
29
|
+
const selectedOnPage = pagedRows.filter((row) => selectedKeys.has(row[rowKey])).length;
|
|
30
|
+
return {
|
|
31
|
+
allSelected: selectedOnPage === pagedRows.length,
|
|
32
|
+
someSelected: selectedOnPage > 0 && selectedOnPage < pagedRows.length,
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/lib/theme-tokens.css
CHANGED
|
@@ -35,9 +35,7 @@
|
|
|
35
35
|
--rw-font-sans: "TASA Orbiter", system-ui, -apple-system, sans-serif;
|
|
36
36
|
--rw-font-mono:
|
|
37
37
|
"Geist Mono Variable", "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
38
|
-
}
|
|
39
38
|
|
|
40
|
-
:root {
|
|
41
39
|
/* Primary — Wisp Teal (brand = #0E7C86, the artifact's deep teal). Deeper
|
|
42
40
|
* and less cyan than Aurora (running/info) so the two stay distinct. */
|
|
43
41
|
--rw-color-wisp-50: oklch(0.97 0.02 205);
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
-
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
-
|
|
4
|
-
<script lang="ts">
|
|
5
|
-
import type { Component, Snippet } from "svelte";
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
title: string;
|
|
9
|
-
icon?: Component;
|
|
10
|
-
children?: Snippet;
|
|
11
|
-
class?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
let { title, icon: Icon, children, class: className = "" }: Props = $props();
|
|
15
|
-
</script>
|
|
16
|
-
|
|
17
|
-
<article
|
|
18
|
-
class="overflow-hidden rounded-[4px] border border-outline bg-surface-raised p-6 hover:border-outline-hover {className}"
|
|
19
|
-
>
|
|
20
|
-
<div class="flex flex-col gap-3">
|
|
21
|
-
{#if Icon}
|
|
22
|
-
<span
|
|
23
|
-
class="inline-flex h-10 w-10 items-center justify-center rounded-[3px] border border-primary-soft-border bg-primary-soft text-primary-soft-text"
|
|
24
|
-
>
|
|
25
|
-
<Icon size={20} />
|
|
26
|
-
</span>
|
|
27
|
-
{/if}
|
|
28
|
-
<h3 class="font-mono text-base text-primary">{title}</h3>
|
|
29
|
-
{#if children}
|
|
30
|
-
<div class="font-sans text-sm leading-relaxed text-on-surface-muted">
|
|
31
|
-
{@render children()}
|
|
32
|
-
</div>
|
|
33
|
-
{/if}
|
|
34
|
-
</div>
|
|
35
|
-
</article>
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
<!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
|
|
2
|
-
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
3
|
-
|
|
4
|
-
<script lang="ts">
|
|
5
|
-
import type { Snippet } from "svelte";
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
columns?: 1 | 2 | 3 | 4;
|
|
9
|
-
gap?: "sm" | "md" | "lg";
|
|
10
|
-
children?: Snippet;
|
|
11
|
-
class?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
let { columns = 3, gap = "md", children, class: className = "" }: Props = $props();
|
|
15
|
-
|
|
16
|
-
const colClasses: Record<1 | 2 | 3 | 4, string> = {
|
|
17
|
-
1: "grid-cols-1",
|
|
18
|
-
2: "grid-cols-1 sm:grid-cols-2",
|
|
19
|
-
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
|
20
|
-
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const gapClasses: Record<"sm" | "md" | "lg", string> = {
|
|
24
|
-
sm: "gap-3",
|
|
25
|
-
md: "gap-4",
|
|
26
|
-
lg: "gap-6",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const colClass = $derived(colClasses[columns]);
|
|
30
|
-
const gapClass = $derived(gapClasses[gap]);
|
|
31
|
-
</script>
|
|
32
|
-
|
|
33
|
-
<div class="grid {colClass} {gapClass} {className}">
|
|
34
|
-
{#if children}
|
|
35
|
-
{@render children()}
|
|
36
|
-
{/if}
|
|
37
|
-
</div>
|