@nauticana/sail 1.1.44 → 1.1.45
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 +31 -7
- package/package.json +1 -1
- package/src/agent/component/studio_page.ts +35 -20
- package/src/component/abstract/base_form.ts +5 -0
- package/src/component/abstract/base_table.ts +23 -29
- package/src/component/abstract/base_view.ts +26 -17
- package/src/component/account/profile_editor.html +15 -8
- package/src/component/account/profile_editor.ts +12 -7
- package/src/component/agency/agency_clients.html +4 -1
- package/src/component/agency/agency_clients.ts +6 -3
- package/src/component/dialog/confirm_dialog.ts +29 -0
- package/src/component/form/field_error.ts +26 -0
- package/src/component/form/form_field.html +5 -10
- package/src/component/form/form_field.ts +10 -0
- package/src/component/login/chpass_component.html +4 -1
- package/src/component/login/chpass_component.ts +6 -3
- package/src/component/login/confirm_chpass_component.html +4 -1
- package/src/component/login/confirm_chpass_component.ts +6 -3
- package/src/component/login/confirm_register_component.html +3 -1
- package/src/component/login/confirm_register_component.ts +6 -3
- package/src/component/login/login_component.html +3 -1
- package/src/component/login/login_component.ts +6 -3
- package/src/component/login/register_component.html +14 -1
- package/src/component/login/register_component.ts +6 -3
- package/src/component/navigation/navigation.html +4 -1
- package/src/component/navigation/navigation.ts +24 -5
- package/src/component/oauth_connection/oauth_connection_panel.html +4 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +9 -5
- package/src/component/payout/bank_info_form.html +6 -2
- package/src/component/payout/bank_info_form.ts +6 -3
- package/src/component/security/api_keys.html +6 -2
- package/src/component/security/api_keys.ts +13 -7
- package/src/component/security/connected_apps.ts +7 -5
- package/src/component/security/sessions.ts +11 -9
- package/src/component/security/trusted_devices.html +2 -1
- package/src/component/security/trusted_devices.ts +18 -13
- package/src/component/security/twofactor_setup.html +7 -3
- package/src/component/security/twofactor_setup.ts +8 -5
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +6 -3
- package/src/component/table/table_detail.ts +1 -1
- package/src/component/table/table_edit.ts +27 -5
- package/src/component/table/table_list.html +21 -5
- package/src/component/table/table_list.ts +74 -67
- package/src/component/table/table_search.ts +2 -2
- package/src/index.ts +4 -0
- package/src/model/appdata.ts +3 -0
- package/src/service/auth.service.ts +19 -6
- package/src/service/billing.service.ts +8 -6
- package/src/service/prompt.service.ts +27 -0
- package/src/util/forms.ts +12 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, linkedSignal, OnInit, signal, Type, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
3
|
-
import { ActivatedRoute, Params } from "@angular/router";
|
|
4
|
-
import { catchError, EMPTY, Observable, switchMap, take, tap } from "rxjs";
|
|
3
|
+
import { ActivatedRoute, Params, Router } from "@angular/router";
|
|
4
|
+
import { catchError, EMPTY, finalize, Observable, switchMap, take, tap } from "rxjs";
|
|
5
5
|
import { errorDetail } from "../../util/errors";
|
|
6
6
|
import { AbstractEditableView } from "../abstract/base_view";
|
|
7
7
|
import { TableEdit } from "./table_edit";
|
|
@@ -9,13 +9,18 @@ import { MatButtonModule } from "@angular/material/button";
|
|
|
9
9
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
10
10
|
import { MatIconModule } from "@angular/material/icon";
|
|
11
11
|
import { MatPaginatorModule, PageEvent } from "@angular/material/paginator";
|
|
12
|
+
import { MatProgressBarModule } from "@angular/material/progress-bar";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
15
|
+
// keel list controls; every other query parameter is a column filter.
|
|
16
|
+
const RESERVED_PARAMS = new Set(['_action', '_order', '_limit', '_offset']);
|
|
12
17
|
|
|
13
18
|
@Component({
|
|
14
19
|
selector: 'sail-table-list',
|
|
15
20
|
templateUrl: './table_list.html',
|
|
16
21
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
17
22
|
encapsulation: ViewEncapsulation.None,
|
|
18
|
-
imports: [MatButtonModule, MatCheckboxModule, MatIconModule, MatPaginatorModule],
|
|
23
|
+
imports: [MatButtonModule, MatCheckboxModule, MatIconModule, MatPaginatorModule, MatProgressBarModule],
|
|
19
24
|
})
|
|
20
25
|
export class TableList extends AbstractEditableView implements OnInit {
|
|
21
26
|
override dialogWidth = '400px';
|
|
@@ -30,32 +35,23 @@ export class TableList extends AbstractEditableView implements OnInit {
|
|
|
30
35
|
override readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
31
36
|
override readonly dialogComponent = linkedSignal<Type<unknown> | undefined, Type<unknown> | undefined>({ source: () => this.dialogComponentInput(), computation: (v, p) => v ?? p?.value });
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
readonly emptyMessage = input('');
|
|
39
|
+
|
|
40
|
+
// Paging and sort live in the URL (_limit, _offset, _order) and run on the server.
|
|
34
41
|
readonly pageIndex = signal(0);
|
|
35
|
-
readonly pageSize = signal(
|
|
42
|
+
readonly pageSize = signal(DEFAULT_PAGE_SIZE);
|
|
36
43
|
readonly total = signal(0);
|
|
37
|
-
readonly pageSizeOptions = [10,
|
|
38
|
-
|
|
39
|
-
// Client-side sort of the CURRENT page — keel's list endpoint has no order
|
|
40
|
-
// parameter, so cross-page ordering isn't possible from the frontend.
|
|
44
|
+
readonly pageSizeOptions = [10, DEFAULT_PAGE_SIZE, 50, 100];
|
|
41
45
|
readonly sortColumn = signal('');
|
|
42
46
|
readonly sortAscending = signal(true);
|
|
43
|
-
readonly
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return [...rows].sort((a, b) => {
|
|
49
|
-
const av = a[column], bv = b[column];
|
|
50
|
-
if (av == null && bv == null) return 0;
|
|
51
|
-
if (av == null) return dir;
|
|
52
|
-
if (bv == null) return -dir;
|
|
53
|
-
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
|
54
|
-
return String(av).localeCompare(String(bv), undefined, { numeric: true }) * dir;
|
|
55
|
-
});
|
|
56
|
-
});
|
|
47
|
+
readonly loading = signal(false);
|
|
48
|
+
private request = 0;
|
|
49
|
+
|
|
50
|
+
readonly activeFilters = computed(() => Object.entries(this.searchTerms())
|
|
51
|
+
.map(([key, value]) => ({ label: this.colCaption(key), value: this.displayValue(key, value) })));
|
|
57
52
|
|
|
58
53
|
private readonly route = inject(ActivatedRoute);
|
|
54
|
+
private readonly router = inject(Router);
|
|
59
55
|
private readonly destroyRef = inject(DestroyRef);
|
|
60
56
|
|
|
61
57
|
ngOnInit() {
|
|
@@ -72,29 +68,25 @@ export class TableList extends AbstractEditableView implements OnInit {
|
|
|
72
68
|
|
|
73
69
|
protected handleDelete(record: Record<string, unknown>): void {
|
|
74
70
|
if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.backendService.delete(this.apiName(), keyFilter).subscribe({
|
|
71
|
+
this.prompt.ask({ title: `Delete this ${this.getCaption()} record?`, action: 'Delete record', destructive: true }, () =>
|
|
72
|
+
this.backendService.delete(this.apiName(), this.getKeyFilters(record)).subscribe({
|
|
78
73
|
next: () => this.fetchRecords(),
|
|
79
74
|
error: (err) => {
|
|
80
75
|
console.error('Delete failed', err);
|
|
81
76
|
this.viewError.set(errorDetail(err, 'Delete failed.'));
|
|
82
77
|
},
|
|
83
|
-
});
|
|
84
|
-
}
|
|
78
|
+
}));
|
|
85
79
|
}
|
|
86
80
|
|
|
87
81
|
protected handleUpdate(result: Record<string, unknown>, isNew: boolean, _original: Record<string, unknown>): void {
|
|
88
82
|
if (!this.requireAuth(() => isNew ? this.canCreate() : this.canUpdate(), isNew ? 'create' : 'update')) return;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
error
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
97
|
-
}
|
|
83
|
+
this.backendService.post(this.apiName(), result).subscribe({
|
|
84
|
+
next: () => this.fetchRecords(),
|
|
85
|
+
error: (err) => {
|
|
86
|
+
console.error('Save failed', err);
|
|
87
|
+
this.viewError.set(errorDetail(err, 'Save failed.'));
|
|
88
|
+
},
|
|
89
|
+
});
|
|
98
90
|
}
|
|
99
91
|
|
|
100
92
|
fetchRecords() {
|
|
@@ -102,13 +94,20 @@ export class TableList extends AbstractEditableView implements OnInit {
|
|
|
102
94
|
this.loadPage().subscribe();
|
|
103
95
|
}
|
|
104
96
|
|
|
97
|
+
/** Only the latest request may change rows or clear `loading`; older responses are dropped. */
|
|
105
98
|
private loadPage(): Observable<unknown> {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
99
|
+
const id = ++this.request;
|
|
100
|
+
const size = this.pageSize();
|
|
101
|
+
const column = this.sortColumn();
|
|
102
|
+
this.records.set([]);
|
|
103
|
+
this.loading.set(true);
|
|
104
|
+
return this.backendService.listPaginated<Record<string, unknown>>(this.apiName(), this.searchTerms(), {
|
|
105
|
+
limit: size,
|
|
106
|
+
offset: this.pageIndex() * size,
|
|
107
|
+
orderBy: column ? `${column} ${this.sortAscending() ? 'ASC' : 'DESC'}` : undefined,
|
|
108
|
+
}).pipe(
|
|
111
109
|
tap((page) => {
|
|
110
|
+
if (id !== this.request) return;
|
|
112
111
|
this.records.set(page.items ?? []);
|
|
113
112
|
this.total.set(page.total ?? page.items?.length ?? 0);
|
|
114
113
|
this.updateDisplayedColumns(page.items ?? []);
|
|
@@ -116,49 +115,57 @@ export class TableList extends AbstractEditableView implements OnInit {
|
|
|
116
115
|
}),
|
|
117
116
|
catchError((err) => {
|
|
118
117
|
console.error('Fetch failed', err);
|
|
119
|
-
this.viewError.set(errorDetail(err, 'Failed to load records.'));
|
|
118
|
+
if (id === this.request) this.viewError.set(errorDetail(err, 'Failed to load records.'));
|
|
120
119
|
return EMPTY;
|
|
121
120
|
}),
|
|
121
|
+
finalize(() => { if (id === this.request) this.loading.set(false); }),
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
onPage(event: PageEvent) {
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
this.fetchRecords();
|
|
126
|
+
const offset = event.pageIndex * event.pageSize;
|
|
127
|
+
this.navigate({ _limit: event.pageSize === DEFAULT_PAGE_SIZE ? null : event.pageSize, _offset: offset || null });
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
onSortColumn(column: string) {
|
|
132
131
|
if (column === 'actions') return;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
const descending = this.sortColumn() === column && this.sortAscending();
|
|
133
|
+
this.navigate({ _order: descending ? column + ' DESC' : column, _offset: null });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Drops the filters and the page; keeps sort and page size. */
|
|
137
|
+
clearSearch() {
|
|
138
|
+
const { _order, _limit } = this.route.snapshot.queryParams;
|
|
139
|
+
this.router.navigate([], { relativeTo: this.route, queryParams: { _order, _limit } });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private navigate(controls: Params) {
|
|
143
|
+
this.router.navigate([], { relativeTo: this.route, queryParams: { ...controls, _action: null }, queryParamsHandling: 'merge' });
|
|
139
144
|
}
|
|
140
145
|
|
|
141
|
-
/** Refresh the row list on successful action — most actions mutate visible state (set_default flipping defaults, etc.). */
|
|
142
146
|
protected override onActionSuccess(): void {
|
|
143
147
|
this.fetchRecords();
|
|
144
148
|
}
|
|
145
149
|
|
|
146
|
-
/** Every params emission
|
|
150
|
+
/** Every params emission replaces the filters, so a bare list URL clears them. */
|
|
147
151
|
private handleQueryParams(params: Params): Observable<unknown> {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (Object.prototype.hasOwnProperty.call(params, key)) {
|
|
152
|
-
if (key === '_action') {
|
|
153
|
-
action = params[key];
|
|
154
|
-
} else {
|
|
155
|
-
newSearchTerms[key] = params[key];
|
|
156
|
-
}
|
|
157
|
-
}
|
|
152
|
+
const filters: {[key: string]: string} = {};
|
|
153
|
+
for (const [key, value] of Object.entries(params)) {
|
|
154
|
+
if (!RESERVED_PARAMS.has(key)) filters[key] = String(value);
|
|
158
155
|
}
|
|
159
|
-
this.searchTerms.set(
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
this.searchTerms.set(filters);
|
|
157
|
+
const limit = Number(params['_limit']);
|
|
158
|
+
const size = this.pageSizeOptions.includes(limit) ? limit : DEFAULT_PAGE_SIZE;
|
|
159
|
+
const offset = Number(params['_offset']);
|
|
160
|
+
this.pageSize.set(size);
|
|
161
|
+
this.pageIndex.set(Number.isInteger(offset) && offset >= 0 && offset % size === 0 ? offset / size : 0);
|
|
162
|
+
const order = String(params['_order'] ?? '').trim().split(/\s+/).filter(Boolean);
|
|
163
|
+
const [column = '', direction = 'ASC'] = order;
|
|
164
|
+
const columns = this.metadataColumns();
|
|
165
|
+
const valid = order.length <= 2 && /^\w+$/.test(column) && columns.includes(column) && /^(ASC|DESC)$/i.test(direction);
|
|
166
|
+
this.sortColumn.set(valid ? column : '');
|
|
167
|
+
this.sortAscending.set(!valid || direction.toUpperCase() === 'ASC');
|
|
168
|
+
if (params['_action'] === 'create') {
|
|
162
169
|
// Gate on appdata so the create dialog gets real column metadata.
|
|
163
170
|
this.cacheService.getAppData().pipe(
|
|
164
171
|
take(1),
|
|
@@ -75,7 +75,7 @@ export class TableSearch extends BaseForm implements OnInit {
|
|
|
75
75
|
onSearch() {
|
|
76
76
|
const targetRoute = this.targetRoute();
|
|
77
77
|
if (!targetRoute) {
|
|
78
|
-
|
|
78
|
+
this.reportError('Navigation target not found for this table.');
|
|
79
79
|
return;
|
|
80
80
|
}
|
|
81
81
|
this.router.navigate([targetRoute], {queryParams: this.buildSearchTerms(this.editableRecord())});
|
|
@@ -103,7 +103,7 @@ export class TableSearch extends BaseForm implements OnInit {
|
|
|
103
103
|
if (!this.requireAuth(() => this.canCreate(), 'create')) return;
|
|
104
104
|
const targetRoute = this.targetRoute();
|
|
105
105
|
if (!targetRoute) {
|
|
106
|
-
|
|
106
|
+
this.reportError('Navigation target not found for this table.');
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
this.router.navigate([targetRoute], {queryParams: {'_action': 'create'}});
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,8 @@ export * from './component/table/table_report';
|
|
|
37
37
|
export * from './component/table/grouped_bulk_action';
|
|
38
38
|
export * from './component/table/before_after_diff';
|
|
39
39
|
export * from './component/navigation/navigation';
|
|
40
|
+
export * from './component/dialog/confirm_dialog';
|
|
41
|
+
export * from './component/form/field_error';
|
|
40
42
|
export * from './component/login/login_component';
|
|
41
43
|
export * from './component/login/register_component';
|
|
42
44
|
export * from './component/login/chpass_component';
|
|
@@ -83,6 +85,7 @@ export * from './component/document/document_upload';
|
|
|
83
85
|
export * from './component/document/partner_documents';
|
|
84
86
|
export * from './component/document/document_review_queue';
|
|
85
87
|
export * from './service/auth.service';
|
|
88
|
+
export * from './service/prompt.service';
|
|
86
89
|
export * from './service/sca_confirmer';
|
|
87
90
|
export * from './service/domain_verifier';
|
|
88
91
|
export * from './service/billing.service';
|
|
@@ -107,5 +110,6 @@ export * from './service/base_rest.service';
|
|
|
107
110
|
export * from './util/text';
|
|
108
111
|
export * from './util/money';
|
|
109
112
|
export * from './util/errors';
|
|
113
|
+
export * from './util/forms';
|
|
110
114
|
export * from './util/password_policy';
|
|
111
115
|
export * from './util/sso';
|
package/src/model/appdata.ts
CHANGED
|
@@ -411,6 +411,9 @@ export interface Subscription {
|
|
|
411
411
|
scheduledAt?: string;
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
// The offer a plan change moves to; a `PlanPrice` fits. Omitted, keel keeps the current terms.
|
|
415
|
+
export type PlanOffer = Pick<PlanPrice, 'billingCycle' | 'termType' | 'termCount'>;
|
|
416
|
+
|
|
414
417
|
// keel's plan-change outcome: applied now, or at the period end.
|
|
415
418
|
export interface PlanChangeResult {
|
|
416
419
|
status: 'changed' | 'scheduled';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Injectable, WritableSignal, inject, signal } from '@angular/core';
|
|
1
|
+
import { Injectable, Injector, WritableSignal, inject, signal } from '@angular/core';
|
|
2
2
|
import { HttpErrorResponse, HttpParams } from '@angular/common/http';
|
|
3
3
|
import { filter, finalize, map, mergeMap, shareReplay, take, tap } from 'rxjs/operators';
|
|
4
4
|
import { PasswordRules } from '../util/password_policy';
|
|
@@ -19,6 +19,7 @@ import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router'
|
|
|
19
19
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
|
|
20
20
|
import { BaseRestService } from './base_rest.service';
|
|
21
21
|
import { resetAuthCircuit } from './auth.interceptor';
|
|
22
|
+
import { PromptService } from './prompt.service';
|
|
22
23
|
|
|
23
24
|
/** Thrown by refreshSession() when the session changed while the rotation was in
|
|
24
25
|
* flight; the result was discarded and the current session is untouched. */
|
|
@@ -65,6 +66,7 @@ interface PermissionRow {
|
|
|
65
66
|
export abstract class BaseAuthService extends BaseRestService {
|
|
66
67
|
protected readonly router = inject(Router);
|
|
67
68
|
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
69
|
+
private readonly injector = inject(Injector);
|
|
68
70
|
|
|
69
71
|
// Reactive cache: computed()s that read metadata through `cache` (via
|
|
70
72
|
// getTableDefinition etc.) recompute automatically when appdata loads/clears.
|
|
@@ -850,12 +852,17 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
850
852
|
// Confirms navigation away when the routed component reports unsaved changes.
|
|
851
853
|
// Opt in by implementing hasUnsavedChanges(): boolean on the component.
|
|
852
854
|
readonly canDeactivate: CanDeactivateFn<{ hasUnsavedChanges?: () => boolean }> = (component) => {
|
|
853
|
-
if (component
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
855
|
+
if (!component?.hasUnsavedChanges?.()) return true;
|
|
856
|
+
return this.injector.get(PromptService).confirm({
|
|
857
|
+
title: 'Leave without saving?', message: 'Your changes on this page are not saved.', action: 'Leave page', destructive: true,
|
|
858
|
+
});
|
|
857
859
|
};
|
|
858
860
|
|
|
861
|
+
/** Route title: the page caption suffixed with `appTitle`. */
|
|
862
|
+
private pageTitle(caption?: string): string | undefined {
|
|
863
|
+
return [caption, this.guiConfig.appTitle].filter(Boolean).join(' | ') || undefined;
|
|
864
|
+
}
|
|
865
|
+
|
|
859
866
|
/** Override in subclasses to add project-specific routes */
|
|
860
867
|
protected buildExtraRoutes(data: ApplicationData): Routes {
|
|
861
868
|
if (this.guiConfig.extraRoutes) {
|
|
@@ -919,7 +926,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
919
926
|
];
|
|
920
927
|
|
|
921
928
|
if (dashboardComponent) {
|
|
922
|
-
newRoutes.push({ path: 'dashboard', component: dashboardComponent });
|
|
929
|
+
newRoutes.push({ path: 'dashboard', component: dashboardComponent, title: this.pageTitle('Dashboard') });
|
|
923
930
|
}
|
|
924
931
|
|
|
925
932
|
for (const menu of menus) {
|
|
@@ -933,12 +940,14 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
933
940
|
const api = apis?.[restUri];
|
|
934
941
|
const apiName = restUri; // bare RestUri; BackendService.crudUrl resolves the version
|
|
935
942
|
const tableName = api?.Table?.TableName ?? restUri;
|
|
943
|
+
const title = this.pageTitle(page.Caption);
|
|
936
944
|
|
|
937
945
|
const overrideComponent = this.getRouteOverride(restUri);
|
|
938
946
|
if (overrideComponent) {
|
|
939
947
|
children.push({
|
|
940
948
|
path: page.ItemId,
|
|
941
949
|
component: overrideComponent,
|
|
950
|
+
title,
|
|
942
951
|
canActivate: [this.canActivate],
|
|
943
952
|
data: { tableName, apiName },
|
|
944
953
|
});
|
|
@@ -950,6 +959,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
950
959
|
children.push({
|
|
951
960
|
path: page.ItemId,
|
|
952
961
|
component: TableReport,
|
|
962
|
+
title,
|
|
953
963
|
canActivate: [this.canActivate],
|
|
954
964
|
data: { tableName, apiName },
|
|
955
965
|
});
|
|
@@ -957,6 +967,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
957
967
|
children.push({
|
|
958
968
|
path: page.ItemId,
|
|
959
969
|
component: TableSearch,
|
|
970
|
+
title,
|
|
960
971
|
canActivate: [this.canActivate],
|
|
961
972
|
data: {
|
|
962
973
|
tableName,
|
|
@@ -967,6 +978,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
967
978
|
children.push({
|
|
968
979
|
path: page.ItemId + '/list',
|
|
969
980
|
component: TableList,
|
|
981
|
+
title,
|
|
970
982
|
canActivate: [this.canActivate],
|
|
971
983
|
data: { tableName, apiName },
|
|
972
984
|
});
|
|
@@ -974,6 +986,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
974
986
|
children.push({
|
|
975
987
|
path: page.ItemId,
|
|
976
988
|
component: TableList,
|
|
989
|
+
title,
|
|
977
990
|
canActivate: [this.canActivate],
|
|
978
991
|
data: { tableName, apiName },
|
|
979
992
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Invoice,
|
|
9
9
|
PaymentMethod,
|
|
10
10
|
PlanChangeResult,
|
|
11
|
+
PlanOffer,
|
|
11
12
|
PlanOverage,
|
|
12
13
|
PortalResponse,
|
|
13
14
|
PublicPlan,
|
|
@@ -53,13 +54,14 @@ export class BillingService extends BaseRestService {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
|
-
*
|
|
57
|
-
* first-time checkout. keel applies it
|
|
58
|
-
* (`scheduled`); the
|
|
59
|
-
* PARTNER_PLAN_SUBSCRIPTION / CHANGE.
|
|
57
|
+
* Move the current subscription to `planId` on `offer` (one of the plan's `prices`;
|
|
58
|
+
* omitted keeps the current terms) — distinct from first-time checkout. keel applies it
|
|
59
|
+
* now (`changed`) or at the period end (`scheduled`); the same plan on the same terms
|
|
60
|
+
* resumes renewal. 403 without PARTNER_PLAN_SUBSCRIPTION / CHANGE.
|
|
60
61
|
*/
|
|
61
|
-
changePlan(planId: string): Observable<PlanChangeResult> {
|
|
62
|
-
|
|
62
|
+
changePlan(planId: string, offer?: PlanOffer): Observable<PlanChangeResult> {
|
|
63
|
+
const terms = offer && { billingCycle: offer.billingCycle, termType: offer.termType, termCount: offer.termCount };
|
|
64
|
+
return this.http.post<PlanChangeResult>(this.url(RestURL.changePlanURL), { planId, ...terms });
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/** What to remove before changing to `planId`; empty when the change is allowed.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { inject, Injectable } from "@angular/core";
|
|
2
|
+
import { MatDialog } from "@angular/material/dialog";
|
|
3
|
+
import { MatSnackBar } from "@angular/material/snack-bar";
|
|
4
|
+
import { map, Observable } from "rxjs";
|
|
5
|
+
import { ConfirmDialog, ConfirmOptions } from "../component/dialog/confirm_dialog";
|
|
6
|
+
|
|
7
|
+
/** Shared confirmation and failure notice, in place of native confirm() and alert(). */
|
|
8
|
+
@Injectable({ providedIn: 'root' })
|
|
9
|
+
export class PromptService {
|
|
10
|
+
private readonly dialog = inject(MatDialog);
|
|
11
|
+
private readonly snack = inject(MatSnackBar);
|
|
12
|
+
|
|
13
|
+
/** Emits true when the action was chosen; false on Cancel, Escape, or backdrop. */
|
|
14
|
+
confirm(options: ConfirmOptions): Observable<boolean> {
|
|
15
|
+
return this.dialog.open(ConfirmDialog, { data: options, role: 'alertdialog', width: '400px' })
|
|
16
|
+
.afterClosed().pipe(map((result) => result === true));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Runs `then` once, only when the action was chosen. */
|
|
20
|
+
ask(options: ConfirmOptions, then: () => void): void {
|
|
21
|
+
this.confirm(options).subscribe((ok) => { if (ok) then(); });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
notify(message: string): void {
|
|
25
|
+
this.snack.open(message, 'Dismiss', { politeness: 'assertive' });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AbstractControl } from "@angular/forms";
|
|
2
|
+
|
|
3
|
+
const INVALID = 'input.ng-invalid, textarea.ng-invalid, select.ng-invalid, mat-select.ng-invalid';
|
|
4
|
+
|
|
5
|
+
/** True when `control` may submit; otherwise marks it touched and focuses the first invalid field in `scope`. */
|
|
6
|
+
export function submittable(control: AbstractControl, scope?: Element | null): boolean {
|
|
7
|
+
if (!control.invalid) return true;
|
|
8
|
+
control.markAllAsTouched();
|
|
9
|
+
const field = scope?.matches(INVALID) ? scope : scope?.querySelector(INVALID);
|
|
10
|
+
(field as HTMLElement | null | undefined)?.focus();
|
|
11
|
+
return false;
|
|
12
|
+
}
|