@runwisp/ui 0.15.1 → 0.16.4
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/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 +39 -16
- 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 +3 -3
- package/src/lib/components/data-grid-selection.test.ts +68 -0
- package/src/lib/components/data-grid-selection.ts +34 -0
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.4",
|
|
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.4",
|
|
53
|
+
"@runwisp/typescript-config": "0.16.4",
|
|
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.4",
|
|
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
|
|
@@ -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,6 +176,8 @@
|
|
|
145
176
|
const stream = streamLogs;
|
|
146
177
|
if (!stream) return;
|
|
147
178
|
|
|
179
|
+
logFetchError = null;
|
|
180
|
+
|
|
148
181
|
let cleanup: (() => void) | undefined;
|
|
149
182
|
let cancelled = false;
|
|
150
183
|
|
|
@@ -156,25 +189,14 @@
|
|
|
156
189
|
// the end. The live stream then picks up after the seeded tail
|
|
157
190
|
// (its disk backfill from that anchor closes the fetch↔stream
|
|
158
191
|
// race, and the daemon dedupes anything already shown).
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const seed = await fetchLogs(id, -TAIL_LINES, -1);
|
|
162
|
-
if (cancelled) return;
|
|
163
|
-
if (isLogEvent(seed)) {
|
|
164
|
-
if (logConsole) logConsole.onStream(seed);
|
|
165
|
-
if (seed.finished) return; // ended run: nothing live to follow
|
|
166
|
-
if (seed.sizeLines > 0) fromLine = seed.sizeLines;
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
// Seed failed — fall back to the SSE tail backfill.
|
|
170
|
-
}
|
|
171
|
-
if (cancelled) return;
|
|
192
|
+
const seeded = await seedConsoleTail(id, () => cancelled);
|
|
193
|
+
if (cancelled || !seeded) return;
|
|
172
194
|
cleanup = stream(
|
|
173
195
|
id,
|
|
174
196
|
(event: LogEvent) => {
|
|
175
197
|
if (logConsole) logConsole.onStream(event);
|
|
176
198
|
},
|
|
177
|
-
{ fromLine },
|
|
199
|
+
{ fromLine: seeded.fromLine },
|
|
178
200
|
);
|
|
179
201
|
})();
|
|
180
202
|
});
|
|
@@ -307,7 +329,7 @@
|
|
|
307
329
|
run.status === "running" ? durationTicker.now.getTime() : undefined,
|
|
308
330
|
)}
|
|
309
331
|
{@const startDelay = runStartDelay(run)}
|
|
310
|
-
{@const startedAt = run.
|
|
332
|
+
{@const startedAt = run.startedAt ?? run.createdAt}
|
|
311
333
|
{@const retry = runRetryLabel(run)}
|
|
312
334
|
{@const paramEntries = run.params ? Object.entries(run.params) : []}
|
|
313
335
|
{@const suffix = instanceSuffix(run.instanceIndex, getInstanceCount(run.taskName))}
|
|
@@ -422,7 +444,7 @@
|
|
|
422
444
|
repeat it. The line wraps at narrow widths instead of
|
|
423
445
|
dropping the date and hiding it behind a hover. -->
|
|
424
446
|
<span data-testid="run-started" class="text-on-surface-muted"
|
|
425
|
-
>{run.
|
|
447
|
+
>{run.startedAt ? formatClockTime(run.startedAt) : "—"}</span
|
|
426
448
|
>
|
|
427
449
|
<span class="text-outline-hover" aria-hidden="true">·</span>
|
|
428
450
|
<span>{formatCalendarDate(startedAt)}</span>
|
|
@@ -763,6 +785,7 @@
|
|
|
763
785
|
{highlightLine}
|
|
764
786
|
{endLabel}
|
|
765
787
|
{endTone}
|
|
788
|
+
error={logFetchError}
|
|
766
789
|
/>
|
|
767
790
|
{/key}
|
|
768
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,
|
|
@@ -151,7 +151,7 @@ export const RUN_STATUS_CONFIG: Record<RunStatus, RunStatusConfig> = {
|
|
|
151
151
|
dot: "bg-danger-surface",
|
|
152
152
|
badge: "bg-danger-soft text-danger-soft-text",
|
|
153
153
|
description:
|
|
154
|
-
"The service kept failing
|
|
154
|
+
"The run (or service instance) kept failing; after restart_attempts consecutive failures, RunWisp gave up restarting it automatically.",
|
|
155
155
|
},
|
|
156
156
|
pending: {
|
|
157
157
|
icon: Clock,
|
|
@@ -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
|
+
}
|