@runwisp/ui 0.9.0 → 0.11.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.
@@ -0,0 +1,299 @@
1
+ <!-- SPDX-FileCopyrightText: PoppyCake, s.r.o. -->
2
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
3
+
4
+ <!--
5
+ Runs filter, behind a single button + popover. On desktop it opens toward the
6
+ run *content* (right of the list rail) so the list it filters stays visible
7
+ and updates live; on phones it drops in as a full-width bottom sheet
8
+ (mobileSheet). The button shows a count badge of active dimensions; the parent
9
+ renders removable chips for each.
10
+
11
+ Status is a five-bucket pick (Running / Succeeded / Failed / Skipped /
12
+ Stopped) with an "Advanced" expander for the individual statuses — buckets are
13
+ pure UI groupings over the same `statuses` array. Time is an independent
14
+ From/To date pair; exit code is a free-form expression (137, >100, >100 <150)
15
+ normalized to an inclusive range at the wire.
16
+
17
+ All controls are NATIVE (<select>, <input>, checkbox, button) — a portalled
18
+ Select component must not nest inside the portalled Popover, since clicking an
19
+ option lands outside the popover's DOM subtree and triggers its outside-click
20
+ close. Every mutation reassigns the whole `filters` object (never an in-place
21
+ property write): the value reaches here through several levels of `bind:`, and
22
+ only a fresh reference reliably re-triggers the parent's fetch effect.
23
+ -->
24
+ <script lang="ts">
25
+ import { Funnel, ChevronRight } from "@lucide/svelte";
26
+ import { END_REASONS, type RunStatus } from "@runwisp/common";
27
+ import Popover from "../Popover.svelte";
28
+ import Badge from "../Badge.svelte";
29
+ import Toggle from "../Toggle.svelte";
30
+ import { RUN_STATUS_CONFIG } from "./status-config.js";
31
+ import {
32
+ activeFilterCount,
33
+ humanizeStatus,
34
+ triggerDescription,
35
+ FILTERABLE_TRIGGERS,
36
+ STATUS_BUCKETS,
37
+ bucketState,
38
+ toggleBucket,
39
+ isoToDayInput,
40
+ dayStartIso,
41
+ dayEndIso,
42
+ isExitCodeExprValid,
43
+ type RunsListFilters,
44
+ } from "./run-filters.js";
45
+
46
+ let {
47
+ filters = $bindable(),
48
+ showTask = false,
49
+ tasks = [],
50
+ }: {
51
+ filters: RunsListFilters;
52
+ showTask?: boolean;
53
+ tasks?: { name: string }[];
54
+ } = $props();
55
+
56
+ // The individual statuses behind the "Advanced" expander, grouped the way
57
+ // the buckets are summarized above.
58
+ const STATUS_GROUPS: { label: string; values: RunStatus[] }[] = [
59
+ { label: "Active", values: ["pending", "running"] },
60
+ { label: "Outcomes", values: [...END_REASONS] },
61
+ ];
62
+
63
+ let advancedOpen = $state(false);
64
+
65
+ const count = $derived(activeFilterCount(filters) + (showTask && filters.task_name ? 1 : 0));
66
+
67
+ const headerClass = "text-xs font-semibold tracking-wide text-on-surface-muted uppercase";
68
+ const labelClass = "text-2xs text-on-surface-faint uppercase";
69
+ const selectClass =
70
+ "h-9 w-full appearance-none rounded-md border border-outline bg-surface-raised px-2.5 text-sm text-on-surface focus:border-ring focus:outline-none";
71
+
72
+ function statusDot(status: RunStatus): string {
73
+ return RUN_STATUS_CONFIG[status].dot.replace(" animate-pulse", "");
74
+ }
75
+
76
+ // Reflect a tri-state bucket onto the native checkbox (no `indeterminate`
77
+ // HTML attribute exists — it must be set on the DOM node).
78
+ function indeterminate(node: HTMLInputElement, value: boolean) {
79
+ node.indeterminate = value;
80
+ return {
81
+ update(v: boolean) {
82
+ node.indeterminate = v;
83
+ },
84
+ };
85
+ }
86
+
87
+ function toggleStatus(value: string) {
88
+ const statuses = filters.statuses.includes(value)
89
+ ? filters.statuses.filter((s) => s !== value)
90
+ : [...filters.statuses, value];
91
+ filters = { ...filters, statuses };
92
+ }
93
+
94
+ // --- Time -------------------------------------------------------------
95
+ //
96
+ // Independent From/To date bounds. A bare date is that day's 00:00 (From) or
97
+ // its end (To), so either edge applies on its own — From alone means
98
+ // "everything since", To alone "everything before", and the same date in
99
+ // both captures the whole day.
100
+
101
+ function setFrom(value: string) {
102
+ filters = { ...filters, created_after: value ? dayStartIso(value) : undefined };
103
+ }
104
+
105
+ function setTo(value: string) {
106
+ filters = { ...filters, created_before: value ? dayEndIso(value) : undefined };
107
+ }
108
+
109
+ // --- Other dimensions -------------------------------------------------
110
+
111
+ function setTask(value: string) {
112
+ filters = { ...filters, task_name: value || undefined };
113
+ }
114
+
115
+ function setTrigger(value: string) {
116
+ filters = { ...filters, triggered_by: value || undefined };
117
+ }
118
+
119
+ // Exit code: a free-form expression (`137`, `>100`, `>100 <150`) normalized
120
+ // to an inclusive range at the wire. Edited in a local buffer for smooth
121
+ // typing and committed on change; the popover content remounts on each open,
122
+ // so the buffer re-seeds from `filters` every time it opens.
123
+ let exitCodeInput = $state(filters.exit_code ?? "");
124
+ const exitCodeValid = $derived(isExitCodeExprValid(exitCodeInput));
125
+
126
+ function commitExitCode() {
127
+ const trimmed = exitCodeInput.trim();
128
+ filters = { ...filters, exit_code: trimmed || undefined };
129
+ }
130
+
131
+ function setRetriesOnly(checked: boolean) {
132
+ filters = { ...filters, retries_only: checked ? true : undefined };
133
+ }
134
+ </script>
135
+
136
+ <Popover placement="right-start" mobileSheet>
137
+ {#snippet trigger()}
138
+ <span
139
+ class="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors {count >
140
+ 0
141
+ ? 'border-wisp-300 bg-primary-soft text-primary-soft-text'
142
+ : 'border-transparent text-on-surface-muted hover:bg-surface-sunken'}"
143
+ title="Filter runs"
144
+ >
145
+ <Funnel size={13} />
146
+ Filter
147
+ {#if count > 0}
148
+ <Badge variant="primary" size="sm" class="px-1.5 py-0">{count}</Badge>
149
+ {/if}
150
+ </span>
151
+ {/snippet}
152
+
153
+ <div class="flex w-full flex-col gap-5 md:w-80">
154
+ <!-- Status: five outcome buckets, with the individual statuses behind
155
+ an Advanced expander for surgical filters. -->
156
+ <section class="flex flex-col gap-2">
157
+ <span class={headerClass}>Status</span>
158
+ <div class="grid grid-cols-2 gap-x-3 gap-y-1.5">
159
+ {#each STATUS_BUCKETS as bucket (bucket.key)}
160
+ {@const state = bucketState(filters.statuses, bucket)}
161
+ <label class="flex cursor-pointer items-center gap-2">
162
+ <input
163
+ type="checkbox"
164
+ checked={state === "on"}
165
+ use:indeterminate={state === "partial"}
166
+ onchange={() => (filters = toggleBucket(filters, bucket))}
167
+ class="size-4 shrink-0 cursor-pointer rounded border-outline accent-primary"
168
+ />
169
+ <span class="size-2.5 shrink-0 rounded-full {bucket.dot}"></span>
170
+ <span class="text-sm text-on-surface">{bucket.label}</span>
171
+ </label>
172
+ {/each}
173
+ </div>
174
+
175
+ <details class="mt-0.5" bind:open={advancedOpen}>
176
+ <summary
177
+ class="flex cursor-pointer items-center gap-1 text-2xs font-medium text-on-surface-muted transition-colors select-none marker:content-none hover:text-on-surface [&::-webkit-details-marker]:hidden"
178
+ >
179
+ <ChevronRight size={12} class={advancedOpen ? "rotate-90" : ""} />
180
+ Advanced — pick exact statuses
181
+ </summary>
182
+ <div class="mt-2 flex flex-col gap-1.5">
183
+ {#each STATUS_GROUPS as group (group.label)}
184
+ <span class={labelClass}>{group.label}</span>
185
+ <div class="grid grid-cols-2 gap-x-2 gap-y-1">
186
+ {#each group.values as status (status)}
187
+ <label
188
+ class="flex cursor-pointer items-center gap-1.5 truncate text-xs"
189
+ >
190
+ <input
191
+ type="checkbox"
192
+ checked={filters.statuses.includes(status)}
193
+ onchange={() => toggleStatus(status)}
194
+ class="size-3.5 shrink-0 cursor-pointer rounded border-outline accent-primary"
195
+ />
196
+ <span class="size-2 shrink-0 rounded-full {statusDot(status)}"
197
+ ></span>
198
+ <span class="truncate text-on-surface">
199
+ {humanizeStatus(status)}
200
+ </span>
201
+ </label>
202
+ {/each}
203
+ </div>
204
+ {/each}
205
+ </div>
206
+ </details>
207
+ </section>
208
+
209
+ <!-- Date range (filters on created_at). Independent From/To bounds: a
210
+ bare date is that day's 00:00 (From) or its end (To), so either edge
211
+ applies on its own and the same date in both captures the day. -->
212
+ <section class="flex flex-col gap-2">
213
+ <span class={headerClass}>Date range</span>
214
+ <div class="grid grid-cols-2 gap-2">
215
+ <label class="flex flex-col gap-1">
216
+ <span class={labelClass}>From</span>
217
+ <input
218
+ type="date"
219
+ value={filters.created_after ? isoToDayInput(filters.created_after) : ""}
220
+ onchange={(e) => setFrom(e.currentTarget.value)}
221
+ class={selectClass}
222
+ />
223
+ </label>
224
+ <label class="flex flex-col gap-1">
225
+ <span class={labelClass}>To</span>
226
+ <input
227
+ type="date"
228
+ value={filters.created_before ? isoToDayInput(filters.created_before) : ""}
229
+ onchange={(e) => setTo(e.currentTarget.value)}
230
+ class={selectClass}
231
+ />
232
+ </label>
233
+ </div>
234
+ </section>
235
+
236
+ <!-- Task (cross-task /runs view only). -->
237
+ {#if showTask}
238
+ <section class="flex flex-col gap-2">
239
+ <span class={headerClass}>Task</span>
240
+ <select
241
+ class={selectClass}
242
+ value={filters.task_name ?? ""}
243
+ onchange={(e) => setTask(e.currentTarget.value)}
244
+ >
245
+ <option value="">Any task</option>
246
+ {#each tasks as task (task.name)}
247
+ <option value={task.name}>{task.name}</option>
248
+ {/each}
249
+ </select>
250
+ </section>
251
+ {/if}
252
+
253
+ <!-- Triggered by. -->
254
+ <section class="flex flex-col gap-2">
255
+ <span class={headerClass}>Triggered by</span>
256
+ <select
257
+ class={selectClass}
258
+ value={filters.triggered_by ?? ""}
259
+ onchange={(e) => setTrigger(e.currentTarget.value)}
260
+ >
261
+ <option value="">Any trigger</option>
262
+ {#each FILTERABLE_TRIGGERS as trigger (trigger)}
263
+ <option value={trigger}>{triggerDescription(trigger)}</option>
264
+ {/each}
265
+ </select>
266
+ </section>
267
+
268
+ <!-- Exit code: a free-form expression parsed to an inclusive range.
269
+ Examples: 137 (exact), >100, <150, or >100 <150 (a window). -->
270
+ <section class="flex flex-col gap-2">
271
+ <span class={headerClass}>Exit code</span>
272
+ <input
273
+ type="text"
274
+ inputmode="text"
275
+ placeholder="e.g. 137, >100, >100 <150"
276
+ bind:value={exitCodeInput}
277
+ onchange={commitExitCode}
278
+ aria-invalid={!exitCodeValid}
279
+ class="{selectClass} {exitCodeValid ? '' : 'border-danger focus:border-danger'}"
280
+ />
281
+ <span class="text-2xs {exitCodeValid ? 'text-on-surface-faint' : 'text-danger'}">
282
+ {exitCodeValid
283
+ ? "Match an exact code, or a range with > >= < <="
284
+ : "Use a number, optionally with > >= < <= (e.g. >100 <150)"}
285
+ </span>
286
+ </section>
287
+
288
+ <!-- Retries. -->
289
+ <section class="flex flex-col gap-2">
290
+ <span class={headerClass}>Retries</span>
291
+ <Toggle
292
+ size="sm"
293
+ checked={Boolean(filters.retries_only)}
294
+ label="Only retried runs"
295
+ onchange={setRetriesOnly}
296
+ />
297
+ </section>
298
+ </div>
299
+ </Popover>