@nauticana/sail 1.1.18 → 1.1.19
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/README.md +32 -1
- package/package.json +1 -1
- package/src/component/dashboard/data_card.html +7 -0
- package/src/component/dashboard/data_card.ts +4 -0
- package/src/component/dashboard/no_source_overlay.html +9 -0
- package/src/component/dashboard/no_source_overlay.ts +21 -0
- package/src/component/table/before_after_diff.html +1 -0
- package/src/component/table/before_after_diff.ts +54 -0
- package/src/component/table/grouped_bulk_action.html +36 -0
- package/src/component/table/grouped_bulk_action.ts +120 -0
- package/src/index.ts +3 -0
- package/src/model/dashboard.ts +1 -1
- package/src/service/base_rest.service.ts +40 -5
package/README.md
CHANGED
|
@@ -876,8 +876,9 @@ For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWith
|
|
|
876
876
|
Horizontal building blocks for an entitlement-aware dashboard. All are presentational (signals in, events out) and ship no CSS — the app styles the documented class hooks and supplies the metric data, chart, and action logic.
|
|
877
877
|
|
|
878
878
|
- **`DashboardShellComponent`** (`sail-dashboard-shell`) — responsive card-grid layout. `[heading]` + a `[dashboardActions]` projection slot for the header; project cards into the default slot. Styles: `.dashboard-shell` / `.dashboard-header` / `.dashboard-grid`.
|
|
879
|
-
- **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`
|
|
879
|
+
- **`DataCardComponent`** (`sail-data-card`) — card chrome for one visualization with `[state]` ∈ `loading | ready | empty | error | locked | no-source`. The chart is supplied as an `<ng-template>` and instantiated **only** in `ready`, so during loading/error/locked/no-source it never exists (no hidden canvas). `description` (required) is the screen-reader text alternative; `[tableHeaders]`/`[tableRows]` render a visually-hidden (`.sr-only`) data table. Emits `upgrade`, `connect`, and `retry`.
|
|
880
880
|
- **`LockedOverlayComponent`** (`sail-locked-overlay`) — entitlement gate. Wraps protected content given as an `<ng-template>`; when `[locked]` the template is never instantiated, so **no data exists behind the gate** (DOM, canvas, or network) — the strict free-tier rule. Emits `upgrade`.
|
|
881
|
+
- **`NoSourceOverlayComponent`** (`sail-no-source-overlay`) — source-readiness gate. Takes `[sourceName]` and emits `connect`; while `[unavailable]`, its protected `<ng-template>` is never instantiated. Use this for included features whose provider is not connected or whose collector is unavailable—never show an upgrade prompt for this state.
|
|
881
882
|
- **`ActionCenterComponent`** (`sail-action-center`) — prioritized next-best-action list from `[actions]: ActionItem[]` (sorted by `priority`, done items show a check). Emits `act`.
|
|
882
883
|
- **`EntitySelectorComponent`** (`sail-entity-selector`) — scope dropdown over `[entities]: EntityOption[]`; resolves `[selected]` or the first active entity and emits `selectionChange`.
|
|
883
884
|
- **`VerificationFlowComponent`** (`sail-verification-flow`) — self-service request → enter-code → verified UI (a 2FA sibling), backend-agnostic: emits `requestVerification` / `confirmCode`, driven by `[step]` + `[errorMessage]` the host feeds back.
|
|
@@ -890,6 +891,36 @@ Horizontal building blocks for an entitlement-aware dashboard. All are presentat
|
|
|
890
891
|
|
|
891
892
|
Models: `DataCardState`, `ActionItem`, `EntityOption`, `VerificationStep`.
|
|
892
893
|
|
|
894
|
+
Analytics clients that need this distinction can call the protected
|
|
895
|
+
`analyticWithStatus<T>()` on `BaseRestService`. It returns
|
|
896
|
+
`{ rows, source: { status, id } }`, populated from `X-Data-Source-Status` and
|
|
897
|
+
`X-Data-Source`; the existing `analytic<T>()` remains `Observable<T[]>`.
|
|
898
|
+
|
|
899
|
+
When the app and its API are on different origins the browser hides both headers
|
|
900
|
+
unless the backend sends
|
|
901
|
+
`Access-Control-Expose-Headers: X-Data-Source-Status, X-Data-Source`; without it
|
|
902
|
+
`source.status` is `null` and a missing source is indistinguishable from an
|
|
903
|
+
empty result.
|
|
904
|
+
|
|
905
|
+
## Grouped review actions and diffs (v1.1.19)
|
|
906
|
+
|
|
907
|
+
`GroupedBulkActionComponent` (`sail-grouped-bulk-action`) groups eligible rows
|
|
908
|
+
with the consumer's `[groupBy]` function and renders an app-chosen bounded
|
|
909
|
+
`[sampleSize]` through a projected `<ng-template>`. The app supplies all domain
|
|
910
|
+
copy and an `[execute]` function returning `{ attempted, succeeded }`. When
|
|
911
|
+
`succeeded < attempted`, the component visibly renders the app's
|
|
912
|
+
`[partialSuccessMessage]`; it never reports a partial operation as complete.
|
|
913
|
+
The outcome message renders above the groups (`role="alert"`,
|
|
914
|
+
`.grouped-bulk-action__message--partial` / `--error`) and stays until the next
|
|
915
|
+
action, so it survives the row refresh that removes its group. Group headings
|
|
916
|
+
are `role="heading"` at `[headingLevel]` (default 3).
|
|
917
|
+
|
|
918
|
+
`BeforeAfterDiffComponent` (`sail-before-after-diff`) renders a
|
|
919
|
+
whitespace-preserving word-level LCS diff. `[maxTokens]` defaults to 2000; above
|
|
920
|
+
the cap it skips the quadratic comparison and shows the whole new value as an
|
|
921
|
+
addition. Additions and deletions are `<ins class="diff-add">` /
|
|
922
|
+
`<del class="diff-del">`, unchanged text `<span class="diff-same">`. The pure `wordDiff()` helper is also exported for custom layouts.
|
|
923
|
+
|
|
893
924
|
## Provider connections (v1.1.9)
|
|
894
925
|
|
|
895
926
|
`ConnectionPanel` is the drop-in UI for keel's `oauth/connect` layer — connect a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.19",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -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';
|
package/src/model/dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
+
}
|