@nauticana/sail 1.1.18 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +57 -1
  2. package/package.json +9 -1
  3. package/src/agent/component/decision_timeline.html +35 -0
  4. package/src/agent/component/decision_timeline.ts +26 -0
  5. package/src/agent/component/prompt_language_editor.html +29 -0
  6. package/src/agent/component/prompt_language_editor.ts +77 -0
  7. package/src/agent/component/prompt_section_editor.html +93 -0
  8. package/src/agent/component/prompt_section_editor.ts +170 -0
  9. package/src/agent/component/release_sections.html +29 -0
  10. package/src/agent/component/release_sections.ts +32 -0
  11. package/src/agent/component/turn_event_list.html +101 -0
  12. package/src/agent/component/turn_event_list.ts +23 -0
  13. package/src/agent/component/turn_stream_view.html +36 -0
  14. package/src/agent/component/turn_stream_view.ts +46 -0
  15. package/src/agent/index.ts +14 -0
  16. package/src/agent/model/agent_studio.ts +198 -0
  17. package/src/agent/model/decision.ts +43 -0
  18. package/src/agent/model/reply_frame.ts +30 -0
  19. package/src/agent/model/turn_event.ts +95 -0
  20. package/src/agent/service/agent_studio.service.ts +85 -0
  21. package/src/agent/service/turn_stream.ts +70 -0
  22. package/src/agent/util/studio_errors.ts +61 -0
  23. package/src/agent/util/turn_timeline.ts +115 -0
  24. package/src/component/dashboard/data_card.html +7 -0
  25. package/src/component/dashboard/data_card.ts +4 -0
  26. package/src/component/dashboard/no_source_overlay.html +9 -0
  27. package/src/component/dashboard/no_source_overlay.ts +21 -0
  28. package/src/component/table/before_after_diff.html +1 -0
  29. package/src/component/table/before_after_diff.ts +54 -0
  30. package/src/component/table/grouped_bulk_action.html +36 -0
  31. package/src/component/table/grouped_bulk_action.ts +120 -0
  32. package/src/index.ts +3 -0
  33. package/src/model/dashboard.ts +1 -1
  34. package/src/service/base_rest.service.ts +40 -5
@@ -0,0 +1,70 @@
1
+ import { computed, signal } from '@angular/core';
2
+ import { TURN_ERROR_CANCELED, TurnReplyFrame, TurnStreamStatus } from '../model/reply_frame';
3
+ import { TurnEvent } from '../model/turn_event';
4
+
5
+ const terminalStatuses: ReadonlySet<TurnStreamStatus> = new Set(['completed', 'canceled', 'failed']);
6
+
7
+ /**
8
+ * Client-side state of one turn's reply stream. Transport stays the app's: it feeds frames to
9
+ * `accept`, reconnects from `cursor()`, and reports an expired replay window. One instance per
10
+ * turn; not injectable.
11
+ */
12
+ export class TurnStream {
13
+ private readonly frames = signal<TurnReplyFrame[]>([]);
14
+ private readonly state = signal<TurnStreamStatus>('idle');
15
+ private readonly lastSequence = signal(0);
16
+
17
+ readonly status = this.state.asReadonly();
18
+ /** Last sequence rendered; a reconnect resumes after it. */
19
+ readonly cursor = this.lastSequence.asReadonly();
20
+ readonly events = computed<TurnEvent[]>(() => this.frames().flatMap((frame) => frame.events));
21
+ readonly finalFrame = computed(() => this.frames().find((frame) => frame.final));
22
+ readonly isTerminal = computed(() => terminalStatuses.has(this.state()));
23
+
24
+ constructor(readonly requestId: string) {}
25
+
26
+ /** Returns false for a frame of another turn, one already rendered, or one after the final. */
27
+ accept(frame: TurnReplyFrame): boolean {
28
+ if (frame.request_id !== this.requestId || this.isTerminal()) return false;
29
+ if (frame.sequence <= this.lastSequence()) return false;
30
+ this.lastSequence.set(frame.sequence);
31
+ this.append(frame);
32
+ return true;
33
+ }
34
+
35
+ /** The reconnect cursor fell behind the retained window; the app now reads the stored result. */
36
+ markReplayExpired(): void {
37
+ if (!this.isTerminal()) this.state.set('replay_expired');
38
+ }
39
+
40
+ /**
41
+ * A final frame rebuilt from the turn record: a payload or an error code, no intermediate
42
+ * events. It completes the turn whatever its sequence.
43
+ */
44
+ acceptStoredResult(frame: TurnReplyFrame): boolean {
45
+ if (frame.request_id !== this.requestId || !frame.final || this.isTerminal()) return false;
46
+ this.append(frame);
47
+ return true;
48
+ }
49
+
50
+ /** Cancel is a request: the turn stays `cancelling` until its final frame arrives. */
51
+ markCancelRequested(): void {
52
+ if (!this.isTerminal()) this.state.set('cancelling');
53
+ }
54
+
55
+ private append(frame: TurnReplyFrame): void {
56
+ this.frames.update((frames) => [...frames, frame]);
57
+ this.state.set(this.statusAfter(frame));
58
+ }
59
+
60
+ private statusAfter(frame: TurnReplyFrame): TurnStreamStatus {
61
+ if (frame.final) {
62
+ if (frame.error_code === TURN_ERROR_CANCELED) return 'canceled';
63
+ return frame.error_code ? 'failed' : 'completed';
64
+ }
65
+ if (this.state() === 'cancelling') return 'cancelling';
66
+ // approval_pending ends the delivery, not the turn: resumed frames follow on the same stream.
67
+ const last = frame.events[frame.events.length - 1];
68
+ return last?.kind === 'approval_pending' ? 'awaiting_approval' : 'streaming';
69
+ }
70
+ }
@@ -0,0 +1,61 @@
1
+ import { AgentFieldError, AgentValidationProblem } from '../model/agent_studio';
2
+
3
+ /**
4
+ * Why a Studio mutation answered 409. The backend reports a stale agent revision and a stale
5
+ * type-defaults revision with the same message, so both are `revision`: reload the draft.
6
+ */
7
+ export type StudioConflictCause = 'revision' | 'sealed' | 'other';
8
+
9
+ type ErrorShape = {
10
+ status?: unknown;
11
+ error?: unknown;
12
+ problem?: unknown;
13
+ headers?: { get?: (name: string) => string | null };
14
+ response?: { headers?: { get?: (name: string) => string | null } };
15
+ };
16
+
17
+ function shapeOf(err: unknown): ErrorShape | undefined {
18
+ return err && typeof err === 'object' ? (err as ErrorShape) : undefined;
19
+ }
20
+
21
+ function detailOf(err: unknown): string {
22
+ const shape = shapeOf(err);
23
+ const body = shape?.problem ?? shape?.error;
24
+ if (typeof body === 'string') return body;
25
+ if (!body || typeof body !== 'object') return '';
26
+ const { detail, message } = body as { detail?: unknown; message?: unknown };
27
+ if (typeof detail === 'string') return detail;
28
+ return typeof message === 'string' ? message : '';
29
+ }
30
+
31
+ export function studioConflictCause(err: unknown): StudioConflictCause | undefined {
32
+ if (shapeOf(err)?.status !== 409) return undefined;
33
+ const detail = detailOf(err);
34
+ if (detail.includes('binding sealed')) return 'sealed';
35
+ if (detail.includes('revision conflict')) return 'revision';
36
+ return 'other';
37
+ }
38
+
39
+ /** Field errors of a 400; the backend carries them as a JSON document in the problem detail. */
40
+ export function studioFieldErrors(err: unknown): AgentFieldError[] {
41
+ if (shapeOf(err)?.status !== 400) return [];
42
+ const detail = detailOf(err);
43
+ if (!detail.startsWith('{')) return [];
44
+ try {
45
+ const problem = JSON.parse(detail) as Partial<AgentValidationProblem>;
46
+ return Array.isArray(problem.fields) ? problem.fields : [];
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /** Whole seconds from `Retry-After` on a 429/503; undefined when the server gave no advice. */
53
+ export function retryAfterSeconds(err: unknown): number | undefined {
54
+ const shape = shapeOf(err);
55
+ if (shape?.status !== 429 && shape?.status !== 503) return undefined;
56
+ const headers = shape.headers ?? shape.response?.headers;
57
+ const raw = headers?.get?.('Retry-After');
58
+ if (!raw) return undefined;
59
+ const seconds = Number(raw);
60
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
61
+ }
@@ -0,0 +1,115 @@
1
+ import {
2
+ EvidenceRef,
3
+ TurnApprovalEvent,
4
+ TurnEffectEvent,
5
+ TurnEvent,
6
+ TurnExtensionEvent,
7
+ TurnProgressEvent,
8
+ TurnToolEvent,
9
+ isKnownTurnEvent,
10
+ } from '../model/turn_event';
11
+
12
+ export type ToolCallApproval = 'pending' | 'resolved';
13
+
14
+ export interface ToolCallView {
15
+ kind: 'tool';
16
+ call_id: string;
17
+ tool_id: string;
18
+ tool_version: string;
19
+ arguments?: unknown;
20
+ result?: TurnToolEvent;
21
+ /** Set only for an `is_error` result; its output is never shown. */
22
+ errorClass?: string;
23
+ effect?: TurnEffectEvent;
24
+ approval?: ToolCallApproval;
25
+ decision?: string;
26
+ }
27
+
28
+ export type TurnTimelineItem =
29
+ | { kind: 'text'; text: string }
30
+ | ToolCallView
31
+ | { kind: 'evidence'; refs: EvidenceRef[] }
32
+ | { kind: 'progress'; progress: TurnProgressEvent }
33
+ | { kind: 'extension'; extension: TurnExtensionEvent }
34
+ | { kind: 'result' };
35
+
36
+ function errorClassOf(tool: TurnToolEvent): string | undefined {
37
+ if (!tool.is_error) return undefined;
38
+ const output = tool.output;
39
+ if (!output || typeof output !== 'object') return '';
40
+ const errorClass = (output as { error?: unknown }).error;
41
+ return typeof errorClass === 'string' ? errorClass : '';
42
+ }
43
+
44
+ /**
45
+ * Folds a turn's events, in frame order, into renderable items: text deltas concatenate, and a
46
+ * call's proposal, result, effect, and approval meet on one item by `call_id`. Unknown kinds
47
+ * are skipped.
48
+ */
49
+ export function buildTurnTimeline(events: readonly unknown[]): TurnTimelineItem[] {
50
+ const items: TurnTimelineItem[] = [];
51
+ const calls = new Map<string, ToolCallView>();
52
+
53
+ const callFor = (source: { call_id?: string; tool_id?: string; tool_version?: string }): ToolCallView => {
54
+ const callId = source.call_id ?? '';
55
+ let call = calls.get(callId);
56
+ if (!call) {
57
+ call = { kind: 'tool', call_id: callId, tool_id: source.tool_id ?? '', tool_version: source.tool_version ?? '' };
58
+ calls.set(callId, call);
59
+ items.push(call);
60
+ }
61
+ return call;
62
+ };
63
+
64
+ const applyApproval = (approval: TurnApprovalEvent | undefined, state: ToolCallApproval): void => {
65
+ if (!approval) return;
66
+ const call = callFor(approval);
67
+ call.approval = state;
68
+ call.decision = approval.decision;
69
+ };
70
+
71
+ for (const candidate of events) {
72
+ if (!isKnownTurnEvent(candidate)) continue;
73
+ const event: TurnEvent = candidate;
74
+ switch (event.kind) {
75
+ case 'text_delta': {
76
+ const last = items[items.length - 1];
77
+ if (last?.kind === 'text') last.text += event.text ?? '';
78
+ else items.push({ kind: 'text', text: event.text ?? '' });
79
+ break;
80
+ }
81
+ case 'tool_proposal':
82
+ if (event.tool) callFor(event.tool).arguments = event.tool.arguments;
83
+ break;
84
+ case 'tool_result':
85
+ if (event.tool) {
86
+ const call = callFor(event.tool);
87
+ call.result = event.tool;
88
+ call.errorClass = errorClassOf(event.tool);
89
+ }
90
+ break;
91
+ case 'effect':
92
+ if (event.effect) callFor(event.effect).effect = event.effect;
93
+ break;
94
+ case 'approval_pending':
95
+ applyApproval(event.approval, 'pending');
96
+ break;
97
+ case 'approval_resolved':
98
+ applyApproval(event.approval, 'resolved');
99
+ break;
100
+ case 'evidence':
101
+ if (event.evidence?.length) items.push({ kind: 'evidence', refs: event.evidence });
102
+ break;
103
+ case 'progress':
104
+ if (event.progress) items.push({ kind: 'progress', progress: event.progress });
105
+ break;
106
+ case 'extension':
107
+ if (event.extension) items.push({ kind: 'extension', extension: event.extension });
108
+ break;
109
+ case 'result':
110
+ items.push({ kind: 'result' });
111
+ break;
112
+ }
113
+ }
114
+ return items;
115
+ }
@@ -33,5 +33,12 @@
33
33
  <button matButton="elevated" type="button" class="primary" (click)="upgrade.emit()">{{ ctaLabel() }}</button>
34
34
  </div>
35
35
  }
36
+ @case ('no-source') {
37
+ <div class="data-card-no-source" role="group" [attr.aria-label]="(heading() || 'Insight') + ' — source unavailable'">
38
+ <p class="no-source-name">{{ sourceName() }}</p>
39
+ <p class="no-source-message">{{ noSourceMessage() }}</p>
40
+ <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ connectLabel() }}</button>
41
+ </div>
42
+ }
36
43
  }
37
44
  </section>
@@ -30,10 +30,14 @@ export class DataCardComponent {
30
30
  readonly errorMessage = input<string>('Could not load this data.');
31
31
  readonly emptyMessage = input<string>('No data yet.');
32
32
  readonly lockedMessage = input<string>('Upgrade to unlock this insight.');
33
+ readonly sourceName = input<string>('data source');
34
+ readonly noSourceMessage = input<string>('Connect the required data source to see this insight.');
33
35
  readonly ctaLabel = input<string>('Upgrade');
36
+ readonly connectLabel = input<string>('Connect');
34
37
  readonly tableHeaders = input<string[]>([]);
35
38
  readonly tableRows = input<(string | number)[][]>([]);
36
39
  readonly upgrade = output<void>();
40
+ readonly connect = output<void>();
37
41
  readonly retry = output<void>();
38
42
 
39
43
  /** The chart, provided as a single <ng-template>; instantiated only when ready. */
@@ -0,0 +1,9 @@
1
+ @if (unavailable()) {
2
+ <div class="no-source-overlay" role="group" [attr.aria-label]="sourceName() + ' — source unavailable'">
3
+ <p class="no-source-name">{{ sourceName() }}</p>
4
+ <p class="no-source-message">{{ message() }}</p>
5
+ <button matButton="elevated" type="button" class="primary" (click)="connect.emit()">{{ ctaLabel() }}</button>
6
+ </div>
7
+ } @else if (content(); as tpl) {
8
+ <ng-container [ngTemplateOutlet]="tpl"></ng-container>
9
+ }
@@ -0,0 +1,21 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, TemplateRef, ViewEncapsulation, contentChild, input, output } from '@angular/core';
3
+ import { MatButtonModule } from '@angular/material/button';
4
+
5
+ /** Source-readiness sibling of LockedOverlayComponent: the template is never instantiated while unavailable. */
6
+ @Component({
7
+ selector: 'sail-no-source-overlay',
8
+ templateUrl: './no_source_overlay.html',
9
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
+ encapsulation: ViewEncapsulation.None,
11
+ imports: [NgTemplateOutlet, MatButtonModule],
12
+ })
13
+ export class NoSourceOverlayComponent {
14
+ readonly unavailable = input.required<boolean>();
15
+ readonly sourceName = input.required<string>();
16
+ readonly message = input<string>('Connect this data source to continue.');
17
+ readonly ctaLabel = input<string>('Connect');
18
+ readonly connect = output<void>();
19
+
20
+ readonly content = contentChild(TemplateRef);
21
+ }
@@ -0,0 +1 @@
1
+ <pre class="before-after-diff">@for (token of tokens(); track $index) {@switch (token.kind) {@case ('add') {<ins class="diff-add">{{ token.text }}</ins>}@case ('del') {<del class="diff-del">{{ token.text }}</del>}@default {<span class="diff-same">{{ token.text }}</span>}}}</pre>
@@ -0,0 +1,54 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+
3
+ export type DiffTokenKind = 'same' | 'add' | 'del';
4
+
5
+ export interface DiffToken {
6
+ text: string;
7
+ kind: DiffTokenKind;
8
+ }
9
+
10
+ /** Whitespace-preserving word-level LCS diff with a bounded work limit. */
11
+ export function wordDiff(before: string, after: string, maxTokens = 2000): DiffToken[] {
12
+ const left = before.split(/(\s+)/).filter((token) => token.length > 0);
13
+ const right = after.split(/(\s+)/).filter((token) => token.length > 0);
14
+ if (left.length > maxTokens || right.length > maxTokens) return [{ text: after, kind: 'add' }];
15
+
16
+ const matrix = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1));
17
+ for (let i = left.length - 1; i >= 0; i--) {
18
+ for (let j = right.length - 1; j >= 0; j--) {
19
+ matrix[i][j] = left[i] === right[j]
20
+ ? matrix[i + 1][j + 1] + 1
21
+ : Math.max(matrix[i + 1][j], matrix[i][j + 1]);
22
+ }
23
+ }
24
+
25
+ const tokens: DiffToken[] = [];
26
+ let i = 0;
27
+ let j = 0;
28
+ while (i < left.length && j < right.length) {
29
+ if (left[i] === right[j]) {
30
+ tokens.push({ text: left[i++], kind: 'same' });
31
+ j++;
32
+ } else if (matrix[i + 1][j] >= matrix[i][j + 1]) {
33
+ tokens.push({ text: left[i++], kind: 'del' });
34
+ } else {
35
+ tokens.push({ text: right[j++], kind: 'add' });
36
+ }
37
+ }
38
+ while (i < left.length) tokens.push({ text: left[i++], kind: 'del' });
39
+ while (j < right.length) tokens.push({ text: right[j++], kind: 'add' });
40
+ return tokens;
41
+ }
42
+
43
+ @Component({
44
+ selector: 'sail-before-after-diff',
45
+ templateUrl: './before_after_diff.html',
46
+ changeDetection: ChangeDetectionStrategy.OnPush,
47
+ encapsulation: ViewEncapsulation.None,
48
+ })
49
+ export class BeforeAfterDiffComponent {
50
+ readonly before = input.required<string>();
51
+ readonly after = input.required<string>();
52
+ readonly maxTokens = input<number>(2000);
53
+ readonly tokens = computed(() => wordDiff(this.before(), this.after(), this.maxTokens()));
54
+ }
@@ -0,0 +1,36 @@
1
+ @if (message(); as outcome) {
2
+ <p class="grouped-bulk-action__message"
3
+ [class.grouped-bulk-action__message--partial]="outcome.kind === 'partial'"
4
+ [class.grouped-bulk-action__message--error]="outcome.kind === 'error'"
5
+ role="alert">{{ outcome.text }}</p>
6
+ }
7
+
8
+ @for (group of groups(); track group.key) {
9
+ <section class="grouped-bulk-action" [attr.data-group-key]="group.key">
10
+ <header class="grouped-bulk-action__header">
11
+ <div class="grouped-bulk-action__heading" role="heading" [attr.aria-level]="headingLevel()">{{ heading()(group) }}</div>
12
+ <button matButton="filled" type="button"
13
+ [disabled]="busyKey() !== null"
14
+ (click)="run(group)">
15
+ {{ actionLabel()(group, busyKey() === group.key) }}
16
+ </button>
17
+ </header>
18
+
19
+ @if (description()(group); as text) {
20
+ <p class="grouped-bulk-action__description">{{ text }}</p>
21
+ }
22
+
23
+ @if (sampleTemplate(); as template) {
24
+ <div class="grouped-bulk-action__sample">
25
+ @for (row of group.sample; track $index) {
26
+ <ng-container [ngTemplateOutlet]="template"
27
+ [ngTemplateOutletContext]="{ $implicit: row, group: group }"></ng-container>
28
+ }
29
+ </div>
30
+ }
31
+
32
+ @if (group.count > group.sample.length) {
33
+ <p class="grouped-bulk-action__remainder">{{ remainderLabel()(group.count - group.sample.length, group) }}</p>
34
+ }
35
+ </section>
36
+ }
@@ -0,0 +1,120 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, DestroyRef, TemplateRef, ViewEncapsulation, computed, contentChild, inject, input, output, signal } from '@angular/core';
3
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { finalize, Observable } from 'rxjs';
6
+
7
+ export type BulkGroupKey = string | number;
8
+
9
+ export interface BulkActionGroup<T> {
10
+ key: BulkGroupKey;
11
+ rows: readonly T[];
12
+ sample: readonly T[];
13
+ count: number;
14
+ }
15
+
16
+ /** Generic outcome used to detect and report partial completion. */
17
+ export interface BulkActionResult {
18
+ attempted: number;
19
+ succeeded: number;
20
+ }
21
+
22
+ export interface BulkActionCompletion<T> {
23
+ group: BulkActionGroup<T>;
24
+ result: BulkActionResult;
25
+ }
26
+
27
+ interface OutcomeMessage {
28
+ text: string;
29
+ kind: 'partial' | 'error';
30
+ }
31
+
32
+ /** The projected template receives each sample row as `$implicit` and its group as `group`. */
33
+ @Component({
34
+ selector: 'sail-grouped-bulk-action',
35
+ templateUrl: './grouped_bulk_action.html',
36
+ changeDetection: ChangeDetectionStrategy.OnPush,
37
+ encapsulation: ViewEncapsulation.None,
38
+ imports: [NgTemplateOutlet, MatButtonModule],
39
+ })
40
+ export class GroupedBulkActionComponent<T> {
41
+ readonly rows = input.required<readonly T[]>();
42
+ readonly groupBy = input.required<(row: T) => BulkGroupKey | null | undefined>();
43
+ readonly eligible = input<(row: T) => boolean>(() => true);
44
+ readonly sampleSize = input.required<number>();
45
+ readonly minimumGroupSize = input<number>(2);
46
+ readonly execute = input.required<(group: BulkActionGroup<T>) => Observable<BulkActionResult>>();
47
+
48
+ readonly heading = input.required<(group: BulkActionGroup<T>) => string>();
49
+ readonly headingLevel = input<number>(3);
50
+ readonly description = input<(group: BulkActionGroup<T>) => string>(() => '');
51
+ readonly actionLabel = input.required<(group: BulkActionGroup<T>, busy: boolean) => string>();
52
+ readonly remainderLabel = input.required<(remaining: number, group: BulkActionGroup<T>) => string>();
53
+ readonly partialSuccessMessage = input.required<(result: BulkActionResult, group: BulkActionGroup<T>) => string>();
54
+ readonly failureMessage = input.required<(error: unknown, group: BulkActionGroup<T>) => string>();
55
+ readonly sortGroups = input<(left: BulkActionGroup<T>, right: BulkActionGroup<T>) => number>(() => 0);
56
+
57
+ readonly finished = output<BulkActionCompletion<T>>();
58
+ readonly failed = output<{ group: BulkActionGroup<T>; error: unknown }>();
59
+
60
+ readonly busyKey = signal<BulkGroupKey | null>(null);
61
+ /** Held outside the groups: a refresh after the action usually removes the group it concerns. */
62
+ readonly message = signal<OutcomeMessage | null>(null);
63
+ readonly sampleTemplate = contentChild<TemplateRef<unknown>>(TemplateRef);
64
+ readonly groups = computed(() => this.buildGroups());
65
+
66
+ private readonly destroyRef = inject(DestroyRef);
67
+
68
+ run(group: BulkActionGroup<T>): void {
69
+ if (this.busyKey() !== null) return;
70
+ this.busyKey.set(group.key);
71
+ this.message.set(null);
72
+ let request: Observable<BulkActionResult>;
73
+ try {
74
+ request = this.execute()(group);
75
+ } catch (error) {
76
+ this.handleFailure(group, error);
77
+ return;
78
+ }
79
+ request.pipe(
80
+ takeUntilDestroyed(this.destroyRef),
81
+ finalize(() => this.busyKey.set(null)),
82
+ ).subscribe({
83
+ next: (result) => {
84
+ if (result.succeeded < result.attempted) {
85
+ this.message.set({ text: this.partialSuccessMessage()(result, group), kind: 'partial' });
86
+ }
87
+ this.finished.emit({ group, result });
88
+ },
89
+ error: (error: unknown) => this.handleFailure(group, error),
90
+ });
91
+ }
92
+
93
+ private buildGroups(): BulkActionGroup<T>[] {
94
+ const grouped = new Map<BulkGroupKey, T[]>();
95
+ for (const row of this.rows()) {
96
+ if (!this.eligible()(row)) continue;
97
+ const key = this.groupBy()(row);
98
+ if (key === null || key === undefined || key === '') continue;
99
+ const groupRows = grouped.get(key) ?? [];
100
+ groupRows.push(row);
101
+ grouped.set(key, groupRows);
102
+ }
103
+ const sampleSize = Math.max(0, this.sampleSize());
104
+ return [...grouped.entries()]
105
+ .filter(([, groupRows]) => groupRows.length >= this.minimumGroupSize())
106
+ .map(([key, groupRows]) => ({
107
+ key,
108
+ rows: groupRows,
109
+ sample: groupRows.slice(0, sampleSize),
110
+ count: groupRows.length,
111
+ }))
112
+ .sort(this.sortGroups());
113
+ }
114
+
115
+ private handleFailure(group: BulkActionGroup<T>, error: unknown): void {
116
+ this.busyKey.set(null);
117
+ this.message.set({ text: this.failureMessage()(error, group), kind: 'error' });
118
+ this.failed.emit({ group, error });
119
+ }
120
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from './component/abstract/base_table';
10
10
  export * from './component/dashboard/dashboard_shell';
11
11
  export * from './component/dashboard/data_card';
12
12
  export * from './component/dashboard/locked_overlay';
13
+ export * from './component/dashboard/no_source_overlay';
13
14
  export * from './component/dashboard/action_center';
14
15
  export * from './component/dashboard/entity_selector';
15
16
  export * from './component/dashboard/verification_flow';
@@ -26,6 +27,8 @@ export * from './component/table/table_edit';
26
27
  export * from './component/table/table_detail';
27
28
  export * from './component/table/table_lookup';
28
29
  export * from './component/table/table_report';
30
+ export * from './component/table/grouped_bulk_action';
31
+ export * from './component/table/before_after_diff';
29
32
  export * from './component/navigation/navigation';
30
33
  export * from './component/login/login_component';
31
34
  export * from './component/login/register_component';
@@ -1,5 +1,5 @@
1
1
  /** Lifecycle state of a data/chart card (sail-data-card). */
2
- export type DataCardState = 'loading' | 'ready' | 'empty' | 'error' | 'locked';
2
+ export type DataCardState = 'loading' | 'ready' | 'empty' | 'error' | 'locked' | 'no-source';
3
3
 
4
4
  /** A prioritized, completable next-best-action (sail-action-center, FR-008). */
5
5
  export interface ActionItem {
@@ -1,6 +1,6 @@
1
1
  import { HttpClient, HttpParams } from '@angular/common/http';
2
2
  import { inject } from '@angular/core';
3
- import { Observable } from 'rxjs';
3
+ import { map, Observable } from 'rxjs';
4
4
  import { RestURL } from './rest_url';
5
5
 
6
6
  /**
@@ -22,10 +22,45 @@ export abstract class BaseRestService {
22
22
  /** GET an analytic endpoint (`{host}{api_prefix}analytic/<endpoint>`), returning
23
23
  * its rows. Optional flat string params become the query string. */
24
24
  protected analytic<T>(endpoint: string, params?: Record<string, string>): Observable<T[]> {
25
- let p = new HttpParams();
26
- for (const k in params) {
27
- if (Object.prototype.hasOwnProperty.call(params, k)) p = p.set(k, params[k]);
25
+ return this.http.get<T[]>(this.analyticUrl(endpoint), { params: this.analyticParams(params) });
26
+ }
27
+
28
+ /** `analytic()` plus the `X-Data-Source-Status` / `X-Data-Source` response headers, so an
29
+ * empty result can be told apart from a missing source. `analytic()` stays array-valued. */
30
+ protected analyticWithStatus<T>(endpoint: string, params?: Record<string, string>): Observable<AnalyticResult<T>> {
31
+ return this.http.get<T[]>(this.analyticUrl(endpoint), {
32
+ params: this.analyticParams(params),
33
+ observe: 'response',
34
+ }).pipe(
35
+ map((response) => ({
36
+ rows: response.body ?? [],
37
+ source: {
38
+ status: response.headers.get('X-Data-Source-Status'),
39
+ id: response.headers.get('X-Data-Source'),
40
+ },
41
+ })),
42
+ );
43
+ }
44
+
45
+ private analyticUrl(endpoint: string): string {
46
+ return this.url(RestURL.api_prefix + 'analytic/' + endpoint);
47
+ }
48
+
49
+ private analyticParams(params?: Record<string, string>): HttpParams {
50
+ let result = new HttpParams();
51
+ for (const key in params) {
52
+ if (Object.prototype.hasOwnProperty.call(params, key)) result = result.set(key, params[key]);
28
53
  }
29
- return this.http.get<T[]>(this.url(RestURL.api_prefix + 'analytic/' + endpoint), { params: p });
54
+ return result;
30
55
  }
31
56
  }
57
+
58
+ export interface AnalyticResult<T> {
59
+ rows: T[];
60
+ source: AnalyticSourceStatus;
61
+ }
62
+
63
+ export interface AnalyticSourceStatus {
64
+ status: string | null;
65
+ id: string | null;
66
+ }