@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.
Files changed (51) hide show
  1. package/README.md +31 -7
  2. package/package.json +1 -1
  3. package/src/agent/component/studio_page.ts +35 -20
  4. package/src/component/abstract/base_form.ts +5 -0
  5. package/src/component/abstract/base_table.ts +23 -29
  6. package/src/component/abstract/base_view.ts +26 -17
  7. package/src/component/account/profile_editor.html +15 -8
  8. package/src/component/account/profile_editor.ts +12 -7
  9. package/src/component/agency/agency_clients.html +4 -1
  10. package/src/component/agency/agency_clients.ts +6 -3
  11. package/src/component/dialog/confirm_dialog.ts +29 -0
  12. package/src/component/form/field_error.ts +26 -0
  13. package/src/component/form/form_field.html +5 -10
  14. package/src/component/form/form_field.ts +10 -0
  15. package/src/component/login/chpass_component.html +4 -1
  16. package/src/component/login/chpass_component.ts +6 -3
  17. package/src/component/login/confirm_chpass_component.html +4 -1
  18. package/src/component/login/confirm_chpass_component.ts +6 -3
  19. package/src/component/login/confirm_register_component.html +3 -1
  20. package/src/component/login/confirm_register_component.ts +6 -3
  21. package/src/component/login/login_component.html +3 -1
  22. package/src/component/login/login_component.ts +6 -3
  23. package/src/component/login/register_component.html +14 -1
  24. package/src/component/login/register_component.ts +6 -3
  25. package/src/component/navigation/navigation.html +4 -1
  26. package/src/component/navigation/navigation.ts +24 -5
  27. package/src/component/oauth_connection/oauth_connection_panel.html +4 -0
  28. package/src/component/oauth_connection/oauth_connection_panel.ts +9 -5
  29. package/src/component/payout/bank_info_form.html +6 -2
  30. package/src/component/payout/bank_info_form.ts +6 -3
  31. package/src/component/security/api_keys.html +6 -2
  32. package/src/component/security/api_keys.ts +13 -7
  33. package/src/component/security/connected_apps.ts +7 -5
  34. package/src/component/security/sessions.ts +11 -9
  35. package/src/component/security/trusted_devices.html +2 -1
  36. package/src/component/security/trusted_devices.ts +18 -13
  37. package/src/component/security/twofactor_setup.html +7 -3
  38. package/src/component/security/twofactor_setup.ts +8 -5
  39. package/src/component/security/twofactor_verify.html +2 -1
  40. package/src/component/security/twofactor_verify.ts +6 -3
  41. package/src/component/table/table_detail.ts +1 -1
  42. package/src/component/table/table_edit.ts +27 -5
  43. package/src/component/table/table_list.html +21 -5
  44. package/src/component/table/table_list.ts +74 -67
  45. package/src/component/table/table_search.ts +2 -2
  46. package/src/index.ts +4 -0
  47. package/src/model/appdata.ts +3 -0
  48. package/src/service/auth.service.ts +19 -6
  49. package/src/service/billing.service.ts +8 -6
  50. package/src/service/prompt.service.ts +27 -0
  51. 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
- // Server-side paging (keel list caps at the requested _limit; total from the envelope).
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(25);
42
+ readonly pageSize = signal(DEFAULT_PAGE_SIZE);
36
43
  readonly total = signal(0);
37
- readonly pageSizeOptions = [10, 25, 50, 100];
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 sortedRecords = computed(() => {
44
- const rows = this.records();
45
- const column = this.sortColumn();
46
- if (!column) return rows;
47
- const dir = this.sortAscending() ? 1 : -1;
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
- if (confirm('Are you sure you want to delete this record?')) {
76
- const keyFilter = this.getKeyFilters(record);
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
- if (confirm('Are you sure you want to save this record?')) {
90
- this.backendService.post(this.apiName(), result).subscribe({
91
- next: () => this.fetchRecords(),
92
- error: (err) => {
93
- console.error('Save failed', err);
94
- this.viewError.set(errorDetail(err, 'Save failed.'));
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
- return this.backendService.listPaginated<Record<string, unknown>>(
107
- this.apiName(),
108
- this.searchTerms(),
109
- { limit: this.pageSize(), offset: this.pageIndex() * this.pageSize() },
110
- ).pipe(
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
- this.pageIndex.set(event.pageIndex);
127
- this.pageSize.set(event.pageSize);
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
- if (this.sortColumn() === column) {
134
- this.sortAscending.update((asc) => !asc);
135
- } else {
136
- this.sortColumn.set(column);
137
- this.sortAscending.set(true);
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 REPLACES searchTerms, so a bare list URL clears stale filters. */
150
+ /** Every params emission replaces the filters, so a bare list URL clears them. */
147
151
  private handleQueryParams(params: Params): Observable<unknown> {
148
- const newSearchTerms: {[key: string]: string} = {};
149
- let action = '';
150
- for (const key in params) {
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(newSearchTerms);
160
- this.pageIndex.set(0);
161
- if (action === 'create') {
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
- alert('Navigation target not found for this table');
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
- alert('Navigation target not found for this table');
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';
@@ -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 && typeof component.hasUnsavedChanges === 'function' && component.hasUnsavedChanges()) {
854
- return window.confirm('You have unsaved changes. Leave this page without saving?');
855
- }
856
- return true;
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
- * Upgrade / downgrade the current subscription to `planId` — distinct from
57
- * first-time checkout. keel applies it now (`changed`) or at the period end
58
- * (`scheduled`); the current plan resumes renewal. 403 without
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
- return this.http.post<PlanChangeResult>(this.url(RestURL.changePlanURL), { planId });
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
+ }