@nauticana/sail 1.1.8 → 1.1.10

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 (79) hide show
  1. package/README.md +36 -3
  2. package/package.json +5 -1
  3. package/src/component/abstract/base_async.ts +9 -11
  4. package/src/component/abstract/base_form.ts +6 -5
  5. package/src/component/abstract/base_table.ts +131 -58
  6. package/src/component/abstract/base_view.ts +43 -27
  7. package/src/component/account/profile_editor.ts +2 -2
  8. package/src/component/billing/payment_methods.ts +1 -2
  9. package/src/component/billing/price_selector.html +2 -1
  10. package/src/component/billing/price_selector.ts +28 -13
  11. package/src/component/billing/trial_banner.ts +7 -1
  12. package/src/component/billing/usage_meter.ts +2 -2
  13. package/src/component/dashboard/data_card.html +1 -1
  14. package/src/component/dashboard/entity_selector.ts +21 -23
  15. package/src/component/form/form_field.html +6 -5
  16. package/src/component/form/form_field.ts +39 -21
  17. package/src/component/form/form_record.html +3 -3
  18. package/src/component/form/form_record.ts +23 -8
  19. package/src/component/form/form_table.html +2 -2
  20. package/src/component/form/form_table.ts +6 -5
  21. package/src/component/login/account_deletion.ts +6 -6
  22. package/src/component/login/chpass_component.html +4 -4
  23. package/src/component/login/chpass_component.ts +0 -1
  24. package/src/component/login/confirm_chpass_component.html +4 -4
  25. package/src/component/login/confirm_chpass_component.ts +1 -4
  26. package/src/component/login/consent_gate.html +6 -6
  27. package/src/component/login/consent_gate.ts +17 -24
  28. package/src/component/login/login_component.html +7 -4
  29. package/src/component/login/login_component.ts +9 -5
  30. package/src/component/login/otp_input.html +13 -5
  31. package/src/component/login/otp_input.ts +34 -27
  32. package/src/component/login/register_component.html +31 -14
  33. package/src/component/login/register_component.ts +80 -89
  34. package/src/component/login/social_login.html +2 -2
  35. package/src/component/login/social_login.ts +20 -13
  36. package/src/component/navigation/navigation.html +2 -2
  37. package/src/component/navigation/navigation.ts +2 -0
  38. package/src/component/oauth_connection/oauth_connection_panel.html +68 -0
  39. package/src/component/oauth_connection/oauth_connection_panel.ts +252 -0
  40. package/src/component/payment/user_payment_methods.html +2 -2
  41. package/src/component/payment/user_payment_methods.ts +12 -23
  42. package/src/component/payout/bank_info_form.html +4 -4
  43. package/src/component/payout/bank_info_form.ts +22 -26
  44. package/src/component/payout/provider_onboarding.html +5 -5
  45. package/src/component/payout/provider_onboarding.ts +28 -48
  46. package/src/component/security/trusted_devices.html +1 -1
  47. package/src/component/security/trusted_devices.ts +0 -1
  48. package/src/component/security/twofactor_setup.html +8 -4
  49. package/src/component/security/twofactor_setup.ts +21 -3
  50. package/src/component/security/twofactor_verify.html +2 -1
  51. package/src/component/security/twofactor_verify.ts +2 -8
  52. package/src/component/table/table_detail.html +15 -14
  53. package/src/component/table/table_detail.ts +62 -41
  54. package/src/component/table/table_edit.html +15 -11
  55. package/src/component/table/table_edit.ts +94 -67
  56. package/src/component/table/table_list.html +45 -12
  57. package/src/component/table/table_list.ts +98 -26
  58. package/src/component/table/table_lookup.html +11 -8
  59. package/src/component/table/table_lookup.ts +45 -37
  60. package/src/component/table/table_report.html +2 -2
  61. package/src/component/table/table_report.ts +37 -31
  62. package/src/component/table/table_search.html +4 -4
  63. package/src/component/table/table_search.ts +36 -45
  64. package/src/config.ts +1 -1
  65. package/src/index.ts +4 -0
  66. package/src/model/appdata.ts +5 -9
  67. package/src/model/common.ts +27 -27
  68. package/src/model/decorator.ts +25 -21
  69. package/src/model/oauth_connection.ts +50 -0
  70. package/src/service/api-response.interceptor.ts +4 -1
  71. package/src/service/auth.interceptor.ts +27 -7
  72. package/src/service/auth.service.ts +188 -134
  73. package/src/service/label.service.ts +6 -5
  74. package/src/service/oauth_connection.service.ts +56 -0
  75. package/src/service/rest_service.ts +57 -13
  76. package/src/service/rest_url.ts +23 -7
  77. package/src/service/script_loader.ts +12 -2
  78. package/src/util/errors.ts +20 -0
  79. package/src/util/money.ts +19 -7
@@ -1,39 +1,49 @@
1
- import { Directive, inject, signal, WritableSignal } from "@angular/core";
1
+ import { computed, Directive, inject, signal, Type, WritableSignal } from "@angular/core";
2
2
  import { BaseTable } from "./base_table";
3
3
  import { MatDialog } from "@angular/material/dialog";
4
4
 
5
+ /** Read-only listing surface; complete as-is (TableLookup). Editing views extend AbstractEditableView. */
5
6
  @Directive()
6
7
  export abstract class BaseView extends BaseTable {
7
- protected readonly dialog = inject(MatDialog);
8
8
  readonly apiName: WritableSignal<string> = signal('');
9
- readonly dialogComponent: WritableSignal<any> = signal(undefined);
10
- records: Array<{[key: string]: any}> = [];
11
- displayedColumns: string[] = [];
12
- searchTerms: {[key: string]: string} = {};
13
- protected hasLinkPermission = false;
9
+ readonly records = signal<Record<string, unknown>[]>([]);
10
+ readonly displayedColumns = signal<string[]>([]);
11
+ readonly searchTerms = signal<{[key: string]: string}>({});
12
+ /** Last load/save failure shown by the view's template ('' when none). */
13
+ readonly viewError = signal('');
14
14
 
15
- protected abstract handleDelete(record: {[key: string]: any}): void;
15
+ protected readonly hasLinkPermission = computed(() => this.canUpdate() || this.canRead());
16
16
 
17
- protected abstract handleUpdate(result: {[key: string]: any}, isNew: boolean, original: {[key: string]: any}): void;
17
+ /** Whether an "actions" column is appended for privileged users; lookup views return false. */
18
+ protected includeActionsColumn(): boolean {
19
+ return true;
20
+ }
18
21
 
19
- updateDisplayedColumns(record: Array<{[key: string]: any}>) {
20
- this.displayedColumns = this.getDisplayedColumns(record);
21
- if (this.displayedColumns.length > 0 &&
22
- !this.displayedColumns.includes('actions') &&
22
+ updateDisplayedColumns(records: Record<string, unknown>[]) {
23
+ const columns = this.getDisplayedColumns(records);
24
+ if (columns.length > 0 &&
25
+ this.includeActionsColumn() &&
26
+ !columns.includes('actions') &&
23
27
  (this.canUpdate() || this.canDelete())
24
- ) this.displayedColumns.push('actions');
25
- this.hasLinkPermission = this.canUpdate() || this.canRead();
28
+ ) columns.push('actions');
29
+ this.displayedColumns.set(columns);
26
30
  }
27
31
 
28
32
  isLinkColumn(column: string): boolean {
29
- return (this.displayedColumns.length > 0 && this.displayedColumns[0] === column && this.hasLinkPermission);
33
+ const columns = this.displayedColumns();
34
+ return (columns.length > 0 && columns[0] === column && this.hasLinkPermission());
30
35
  }
36
+ }
31
37
 
32
- trackByRecord(index: number, record: {[key: string]: any}): any {
33
- const tableDef = this.cacheService.getTableDefinition(this.tableName());
34
- if (!tableDef || !tableDef.Keys || tableDef.Keys.length === 0) return index;
35
- return tableDef.Keys.map((key) => record[key.PascalName]).join('_');
36
- }
38
+ /** Listing surface with dialog-based create/edit/delete; subclasses implement persistence. */
39
+ @Directive()
40
+ export abstract class AbstractEditableView extends BaseView {
41
+ protected readonly dialog = inject(MatDialog);
42
+ readonly dialogComponent: WritableSignal<Type<unknown> | undefined> = signal(undefined);
43
+
44
+ protected abstract handleDelete(record: Record<string, unknown>): void;
45
+
46
+ protected abstract handleUpdate(result: Record<string, unknown>, isNew: boolean, original: Record<string, unknown>): void;
37
47
 
38
48
  /**
39
49
  * Shared row-dialog opener. Builds the dialog data and wires the result
@@ -41,12 +51,17 @@ export abstract class BaseView extends BaseTable {
41
51
  * `addRecord()` (new row + empty `original`) and `editRecord()` (existing
42
52
  * row passed through verbatim).
43
53
  */
44
- private openRecordDialog(record: {[key: string]: any}, isNew: boolean): void {
54
+ private openRecordDialog(record: Record<string, unknown>, isNew: boolean): void {
55
+ const component = this.dialogComponent();
56
+ if (!component) {
57
+ console.error('sail: no dialogComponent configured for', this.tableName());
58
+ return;
59
+ }
45
60
  const dialogData = this.getDialogData(record, isNew);
46
61
  const apiName = this.apiName();
47
62
  if (apiName) dialogData['apiName'] = apiName;
48
63
 
49
- this.dialog.open(this.dialogComponent(), {
64
+ this.dialog.open(component, {
50
65
  width: '90vw',
51
66
  maxWidth: '1200px',
52
67
  maxHeight: '90vh',
@@ -62,8 +77,9 @@ export abstract class BaseView extends BaseTable {
62
77
  const record = this.emptyRecord();
63
78
  // Seed columns from an existing row when emptyRecord() returned just the
64
79
  // op-code field (table metadata wasn't loaded yet).
65
- if (Object.keys(record).length === 1 && this.records.length > 0) {
66
- Object.keys(this.records[0]).forEach((key) => { record[key] = ''; });
80
+ const rows = this.records();
81
+ if (Object.keys(record).length === 1 && rows.length > 0) {
82
+ Object.keys(rows[0]).forEach((key) => { record[key] = ''; });
67
83
  }
68
84
  this.openRecordDialog(record, true);
69
85
  }
@@ -75,12 +91,12 @@ export abstract class BaseView extends BaseTable {
75
91
  * buttons are gated by the same permissions.
76
92
  * Block only when neither read nor update is granted.
77
93
  */
78
- editRecord(record: {[key: string]: any}) {
94
+ editRecord(record: Record<string, unknown>) {
79
95
  if (!this.canUpdate() && !this.requireAuth(() => this.canRead(), 'view')) return;
80
96
  this.openRecordDialog(record, false);
81
97
  }
82
98
 
83
- deleteRecord(record: {[key: string]: any}) {
99
+ deleteRecord(record: Record<string, unknown>) {
84
100
  if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
85
101
  this.handleDelete(record);
86
102
  }
@@ -96,7 +96,7 @@ export class ProfileEditorComponent implements OnInit {
96
96
  this.loading.set(true);
97
97
  this.emailErr.set('');
98
98
  this.auth.confirmEmailChange(this.newEmail.value!, Number(this.emailCode.value)).subscribe({
99
- next: () => { this.loading.set(false); this.email.set(this.newEmail.value!); this.cancelEmailChange(); this.profileMsg.set('Email updated.'); },
99
+ next: () => { this.loading.set(false); this.email.set(this.newEmail.value!); this.cancelEmailChange(); this.emailMsg.set('Email updated.'); },
100
100
  error: (e) => { this.loading.set(false); this.emailErr.set(this.msg(e, 'Invalid or expired code.')); },
101
101
  });
102
102
  }
@@ -128,7 +128,7 @@ export class ProfileEditorComponent implements OnInit {
128
128
  this.loading.set(true);
129
129
  this.phoneErr.set('');
130
130
  this.auth.confirmPhoneChange(this.newPhone.value!, Number(this.phoneCode.value)).subscribe({
131
- next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.profileMsg.set('Phone updated.'); },
131
+ next: () => { this.loading.set(false); this.phone.set(this.newPhone.value!); this.cancelPhoneChange(); this.phoneMsg.set('Phone updated.'); },
132
132
  error: (e) => { this.loading.set(false); this.phoneErr.set(this.msg(e, 'Invalid or expired code.')); },
133
133
  });
134
134
  }
@@ -7,8 +7,7 @@ import { PaymentMethod } from '../../model/appdata';
7
7
 
8
8
  /**
9
9
  * Lists the partner's saved payment methods. Inherits loading/error state
10
- * from BaseAsyncView. The "Manage in Stripe" button is a placeholder — pass
11
- * `[portalUrl]` to show it; otherwise the button is hidden.
10
+ * from BaseAsync. Pair with `<sail-portal-button>` for provider-side management.
12
11
  */
13
12
  @Component({
14
13
  selector: 'sail-payment-methods',
@@ -1,9 +1,10 @@
1
- <div class="price-selector" role="radiogroup" aria-label="Billing options">
1
+ <div class="price-selector" role="radiogroup" aria-label="Billing options" (keydown)="onKeydown($event)">
2
2
  @for (offer of offers(); track offer.key) {
3
3
  <button class="price-offer" type="button"
4
4
  role="radio"
5
5
  [class.price-selected]="offer.key === selected()"
6
6
  [attr.aria-checked]="offer.key === selected()"
7
+ [attr.tabindex]="tabIndexFor(offer, $index)"
7
8
  (click)="onSelect(offer)">
8
9
  <span class="price-amount">{{ offer.amountText }}</span>
9
10
  <span class="price-cycle">/ {{ offer.cycleText }}</span>
@@ -1,6 +1,6 @@
1
1
  import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
2
  import { PlanPrice } from '../../model/appdata';
3
- import { formatCurrency } from '../../util/money';
3
+ import { formatCurrency, PERIOD_TYPE_INFO } from '../../util/money';
4
4
 
5
5
  /** Per-offer view model for accessible, currency-formatted rendering. */
6
6
  interface OfferRow {
@@ -27,14 +27,6 @@ interface OfferRow {
27
27
  encapsulation: ViewEncapsulation.None,
28
28
  })
29
29
  export class PriceSelectorComponent {
30
- private static readonly DEFAULT_CYCLE: Record<string, string> = {
31
- D: 'Daily',
32
- W: 'Weekly',
33
- M: 'Monthly',
34
- Q: 'Quarterly',
35
- A: 'Annual',
36
- };
37
-
38
30
  readonly prices = input<PlanPrice[]>([]);
39
31
  /** Selected offer key — a `priceId`, or the composite key when priceId is empty. */
40
32
  readonly selected = input<string | undefined>(undefined);
@@ -45,7 +37,9 @@ export class PriceSelectorComponent {
45
37
  readonly offers = computed<OfferRow[]>(() => {
46
38
  const labels = this.cycleLabels();
47
39
  const cycleLabel = (code: string) =>
48
- labels[code] ?? PriceSelectorComponent.DEFAULT_CYCLE[code] ?? code;
40
+ labels[code] ?? PERIOD_TYPE_INFO[code]?.label ?? code;
41
+ const termNoun = (code: string) =>
42
+ PERIOD_TYPE_INFO[code]?.noun ?? cycleLabel(code).toLowerCase();
49
43
  return this.prices().map((p) => {
50
44
  const singleCycle = p.termCount === 1 && p.termType === p.billingCycle;
51
45
  return {
@@ -53,9 +47,7 @@ export class PriceSelectorComponent {
53
47
  price: p,
54
48
  amountText: formatCurrency(p.amount, p.currency),
55
49
  cycleText: cycleLabel(p.billingCycle),
56
- termText: singleCycle
57
- ? ''
58
- : `${p.termCount} ${cycleLabel(p.termType)}${p.termCount > 1 ? 's' : ''} commitment`,
50
+ termText: singleCycle ? '' : `${p.termCount}-${termNoun(p.termType)} commitment`,
59
51
  };
60
52
  });
61
53
  });
@@ -63,4 +55,27 @@ export class PriceSelectorComponent {
63
55
  onSelect(offer: OfferRow): void {
64
56
  this.selectionChange.emit(offer.price);
65
57
  }
58
+
59
+ /** Roving-radio tabindex: the selected offer (or the first) is the tab stop. */
60
+ tabIndexFor(offer: OfferRow, index: number): number {
61
+ const sel = this.selected();
62
+ if (sel) return offer.key === sel ? 0 : -1;
63
+ return index === 0 ? 0 : -1;
64
+ }
65
+
66
+ /** Arrow-key navigation required by the radio pattern (WCAG 4.1.2). */
67
+ onKeydown(event: KeyboardEvent): void {
68
+ const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
69
+ const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
70
+ if (!forward && !backward) return;
71
+ event.preventDefault();
72
+ const group = event.currentTarget as HTMLElement;
73
+ const buttons = Array.from(group.querySelectorAll<HTMLButtonElement>('[role="radio"]'));
74
+ if (!buttons.length) return;
75
+ const current = buttons.indexOf(event.target as HTMLButtonElement);
76
+ const next = (current + (forward ? 1 : -1) + buttons.length) % buttons.length;
77
+ buttons[next].focus();
78
+ const offer = this.offers()[next];
79
+ if (offer) this.onSelect(offer);
80
+ }
66
81
  }
@@ -1,4 +1,6 @@
1
1
  import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
2
+ import { toSignal } from '@angular/core/rxjs-interop';
3
+ import { map, timer } from 'rxjs';
2
4
 
3
5
  /**
4
6
  * "Trial ends in N days" banner, bound to `Subscription.trialEnd`. Renders
@@ -15,12 +17,16 @@ export class TrialBannerComponent {
15
17
  /** ISO timestamp the trial ends (Subscription.trialEnd). */
16
18
  readonly trialEnd = input<string | undefined>(undefined);
17
19
 
20
+ // Hourly clock tick — a computed reading Date.now() directly would freeze
21
+ // at mount time and show a stale day count across midnight.
22
+ private readonly now = toSignal(timer(0, 3_600_000).pipe(map(() => Date.now())), { requireSync: true });
23
+
18
24
  readonly daysLeft = computed<number>(() => {
19
25
  const raw = this.trialEnd();
20
26
  if (!raw) return 0;
21
27
  const end = Date.parse(raw);
22
28
  if (Number.isNaN(end)) return 0;
23
- return Math.ceil((end - Date.now()) / 86_400_000);
29
+ return Math.ceil((end - this.now()) / 86_400_000);
24
30
  });
25
31
 
26
32
  readonly message = computed<string>(() => {
@@ -34,9 +34,9 @@ export class UsageMeterComponent {
34
34
  return {
35
35
  resource: m.resource,
36
36
  hasBar,
37
- percent: hasBar ? Math.min(100, Math.round((m.used / m.limit) * 100)) : 0,
37
+ percent: hasBar ? Math.min(100, Math.max(0, Math.round((m.used / m.limit) * 100))) : 0,
38
38
  ariaMax: m.limit,
39
- ariaNow: hasBar ? Math.min(m.used, m.limit) : m.used,
39
+ ariaNow: hasBar ? Math.min(Math.max(0, m.used), m.limit) : m.used,
40
40
  valueText: `${m.used} / ${m.limit < 0 ? 'Unlimited' : m.limit}`,
41
41
  };
42
42
  }),
@@ -3,7 +3,7 @@
3
3
 
4
4
  @switch (state()) {
5
5
  @case ('loading') {
6
- <div class="data-card-loading"><mat-spinner diameter="28"></mat-spinner></div>
6
+ <div class="data-card-loading"><mat-spinner diameter="28" [attr.aria-label]="(heading() || 'Card') + ' loading'"></mat-spinner></div>
7
7
  }
8
8
  @case ('ready') {
9
9
  <p class="sr-only">{{ description() }}</p>
@@ -1,4 +1,5 @@
1
- import { ChangeDetectionStrategy, Component, ViewEncapsulation, effect, input, output, signal } from '@angular/core';
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, linkedSignal } from '@angular/core';
2
+ import { outputFromObservable, toObservable } from '@angular/core/rxjs-interop';
2
3
  import { MatFormFieldModule } from '@angular/material/form-field';
3
4
  import { MatSelectModule } from '@angular/material/select';
4
5
  import { EntityOption } from '../../model/dashboard';
@@ -19,32 +20,29 @@ export class EntitySelectorComponent {
19
20
  readonly entities = input<EntityOption[]>([]);
20
21
  readonly selected = input<string | undefined>(undefined);
21
22
  readonly label = input<string>('Business');
22
- readonly selectionChange = output<string | undefined>();
23
23
 
24
- readonly value = signal<string | undefined>(undefined);
24
+ /**
25
+ * Re-resolves only when the INPUTS change (a user pick via `.set()` sticks —
26
+ * an unchanged `[selected]` binding can never snap the selection back):
27
+ * a newly-changed valid `selected` wins, else the current pick if still
28
+ * valid, else the first active entity, else undefined (host clears stale data).
29
+ */
30
+ readonly value = linkedSignal<{ opts: EntityOption[]; sel?: string }, string | undefined>({
31
+ source: () => ({ opts: this.entities(), sel: this.selected() }),
32
+ computation: (src, prev) => {
33
+ const isActive = (id?: string) => !!id && src.opts.some((e) => e.id === id && e.active !== false);
34
+ const selChanged = src.sel !== prev?.source.sel;
35
+ if (selChanged && isActive(src.sel)) return src.sel;
36
+ if (isActive(prev?.value)) return prev?.value;
37
+ if (isActive(src.sel)) return src.sel;
38
+ return src.opts.find((e) => e.active !== false)?.id;
39
+ },
40
+ });
25
41
 
26
- constructor() {
27
- // Explicit selection wins; else keep the current pick if still valid; else the
28
- // first active entity. "Valid" means present AND active, so a scope that goes
29
- // inactive (or vanishes) is dropped. Emit on every change, including a clear
30
- // (undefined) when no active option remains, so the host stops showing stale data.
31
- effect(() => {
32
- const opts = this.entities();
33
- const isActive = (id?: string) => !!id && opts.some((e) => e.id === id && e.active !== false);
34
- const cur = this.value();
35
- const resolved =
36
- isActive(this.selected()) ? this.selected()
37
- : isActive(cur) ? cur
38
- : opts.find((e) => e.active !== false)?.id;
39
- if (resolved !== cur) {
40
- this.value.set(resolved);
41
- this.selectionChange.emit(resolved);
42
- }
43
- });
44
- }
42
+ // Single emission path — signals already dedupe unchanged values.
43
+ readonly selectionChange = outputFromObservable(toObservable(this.value));
45
44
 
46
45
  onChange(id: string): void {
47
46
  this.value.set(id);
48
- this.selectionChange.emit(id);
49
47
  }
50
48
  }
@@ -1,8 +1,9 @@
1
1
  @if (isLookupTableSearch()) {
2
2
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
3
3
  <mat-label>{{ colCaption(field()) }}</mat-label>
4
- <input matInput [value]="isReadonly ? displayCaption : value()" [required]="required" [readonly]="isReadonly">
5
- <button matIconButton matSuffix (click)="openLookup()" [disabled]="isReadonly">
4
+ <input matInput [value]="isReadonly ? displayCaption : inputValue" [required]="required" readonly>
5
+ <button matIconButton matSuffix type="button" (click)="openLookup()" [disabled]="isReadonly"
6
+ [attr.aria-label]="'Look up ' + colCaption(field())">
6
7
  <mat-icon>search</mat-icon>
7
8
  </button>
8
9
  </mat-form-field>
@@ -14,7 +15,7 @@
14
15
  } @else if (inputType === 'select') {
15
16
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
16
17
  <mat-label>{{ colCaption(field()) }}</mat-label>
17
- <mat-select [value]="value()" (selectionChange)="onSelectChange($event);">
18
+ <mat-select [value]="value()" [required]="required" (selectionChange)="onSelectChange($event);">
18
19
  @for (option of options; track option.Value) {
19
20
  <mat-option [value]="option.Value">{{option.Caption}}</mat-option>
20
21
  }
@@ -31,7 +32,7 @@
31
32
  <mat-form-field [appearance]="appearance()" [subscriptSizing]="subscriptSizing()">
32
33
  <mat-label>{{ colCaption(field()) }}</mat-label>
33
34
  <textarea matInput
34
- [value]="value()"
35
+ [value]="inputValue"
35
36
  (input)="onInputChange($event);"
36
37
  [disabled]="isReadonly"
37
38
  [required]="required"
@@ -44,7 +45,7 @@
44
45
  <mat-label>{{ colCaption(field()) }}</mat-label>
45
46
  <input matInput
46
47
  [type]="inputType"
47
- [value]="value()"
48
+ [value]="inputValue"
48
49
  (input)="onInputChange($event);"
49
50
  [disabled]="isReadonly"
50
51
  [required]="required"
@@ -1,11 +1,11 @@
1
1
  import { ChangeDetectionStrategy, Component, inject, input, linkedSignal, output, ViewEncapsulation } from "@angular/core";
2
2
  import { MatButtonModule } from "@angular/material/button";
3
- import { MatCheckboxModule } from "@angular/material/checkbox";
3
+ import { MatCheckboxChange, MatCheckboxModule } from "@angular/material/checkbox";
4
4
  import { MatDialog } from "@angular/material/dialog";
5
5
  import { MatFormFieldModule } from "@angular/material/form-field";
6
6
  import { MatIconModule } from "@angular/material/icon";
7
7
  import { MatInputModule } from "@angular/material/input";
8
- import { MatSelectModule } from "@angular/material/select";
8
+ import { MatSelectChange, MatSelectModule } from "@angular/material/select";
9
9
  import { BaseTable } from "../abstract/base_table";
10
10
  import { ConstantValue, LookupStyle } from "../../model/common";
11
11
  import { TableLookup } from "../table/table_lookup";
@@ -32,9 +32,9 @@ import { TableLookup } from "../table/table_lookup";
32
32
  })
33
33
  export class DynamicField extends BaseTable {
34
34
  readonly tableNameInput = input('', { alias: 'tableName' });
35
- readonly value = input<any>(undefined);
36
- readonly valueChange = output<any>();
37
- readonly recordUpdate = output<{[key: string]: any}>();
35
+ readonly value = input<unknown>(undefined);
36
+ readonly valueChange = output<unknown>();
37
+ readonly recordUpdate = output<{[key: string]: unknown}>();
38
38
  readonly field = input('');
39
39
  readonly label = input('');
40
40
  readonly readonlyField = input(false, {alias: 'readonly'});
@@ -103,9 +103,17 @@ export class DynamicField extends BaseTable {
103
103
  return !!(this.col && this.col.LookupTable && this.col.LookupStyle === LookupStyle.Search);
104
104
  }
105
105
 
106
- get maxLength() {
107
- if (!this.col) return 0;
108
- return this.col.Size;
106
+ /** null when metadata is missing/zero — [attr.maxLength] then omits the
107
+ * attribute (maxlength="0" would block all typing). */
108
+ get maxLength(): number | null {
109
+ const size = this.col?.Size ?? 0;
110
+ return size > 0 ? size : null;
111
+ }
112
+
113
+ /** Input-safe string form of the bound value. */
114
+ get inputValue(): string {
115
+ const v = this.value();
116
+ return v === null || v === undefined ? '' : String(v);
109
117
  }
110
118
 
111
119
  get scale() {
@@ -132,27 +140,37 @@ export class DynamicField extends BaseTable {
132
140
  return this.displayValue(this.field(), this.value());
133
141
  }
134
142
 
135
- onInputChange(event: any) {
136
- if (event && event.target) this.valueChange.emit(event.target.value);
143
+ onInputChange(event: Event) {
144
+ const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
145
+ if (target) this.valueChange.emit(this.coerce(target.value));
137
146
  }
138
147
 
139
- onSelectChange(event: any) {
140
- if (event) this.valueChange.emit(event.value);
148
+ /** Numeric columns emit numbers, not the input's raw string. */
149
+ private coerce(raw: string): unknown {
150
+ if (raw !== '' && this.isNumber(this.field())) {
151
+ const n = Number(raw);
152
+ if (!isNaN(n)) return n;
153
+ }
154
+ return raw;
141
155
  }
142
156
 
143
- onCheckboxChange(event: any) {
144
- if (event) this.valueChange.emit(event.checked);
157
+ onSelectChange(event: MatSelectChange) {
158
+ this.valueChange.emit(event.value);
145
159
  }
146
160
 
147
- updateForeignKeys(parentRecord: any) {
161
+ onCheckboxChange(event: MatCheckboxChange) {
162
+ this.valueChange.emit(event.checked);
163
+ }
164
+
165
+ updateForeignKeys(parentRecord: Record<string, unknown>) {
148
166
  if (!this.col || !this.col.LookupTable) return;
149
167
  const fk = this.getForeignKeyConfig(this.col.LookupTable);
150
- const updates: {[key: string]: any} = {};
151
- if (fk && fk.fk) {
152
- fk.fk.Columns.forEach((column, index) => {
153
- updates[column.PascalName] = parentRecord[fk.parent.Keys[index].PascalName];
154
- });
155
- }
168
+ // No FK config → nothing to map; emitting would wipe the current value.
169
+ if (!fk?.fk || fk.fk.Columns.length !== fk.parent.Keys.length) return;
170
+ const updates: {[key: string]: unknown} = {};
171
+ fk.fk.Columns.forEach((column, index) => {
172
+ updates[column.PascalName] = parentRecord[fk.parent.Keys[index].PascalName];
173
+ });
156
174
  this.recordUpdate.emit(updates);
157
175
  this.valueChange.emit(updates[this.field()]);
158
176
  }
@@ -1,10 +1,10 @@
1
1
  <div class="form-container">
2
2
  @for (key of computedFields(); track key) {
3
- @if (!isArray(record()[key])) {
3
+ @if (!isArray(working()[key])) {
4
4
  <sail-dynamic-field
5
5
  [tableName]="tableName()"
6
- [value]="record()[key]"
7
- (valueChange)="record()[key]=$event"
6
+ [value]="working()[key]"
7
+ (valueChange)="onFieldChange(key, $event)"
8
8
  (recordUpdate)="onRecordUpdate($event)"
9
9
  [field]="key"
10
10
  [readonly]="isReadOnly()(key)">
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, computed, inject, input, linkedSignal, output, ViewEncapsulation } from "@angular/core";
2
2
  import { DynamicField } from "./form_field";
3
3
  import { BaseAuthService } from "../../service/auth.service";
4
4
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
@@ -13,10 +13,15 @@ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
13
13
  export class RecordForm {
14
14
  private readonly auth = inject(BaseAuthService);
15
15
  private readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
16
- readonly record = input<{[key: string]: any}>({});
16
+ readonly record = input<{[key: string]: unknown}>({});
17
17
  readonly tableName = input('');
18
18
  readonly fields = input<string[]>([]);
19
19
  readonly isReadOnly = input<(key: string) => boolean>(() => false);
20
+ /** Full updated record on every field edit. The input object is never mutated. */
21
+ readonly recordChange = output<{[key: string]: unknown}>();
22
+
23
+ /** Local working copy — resets whenever the parent binds a new record object. */
24
+ readonly working = linkedSignal(() => ({...this.record()}));
20
25
 
21
26
  private get hiddenFields(): Set<string> {
22
27
  const global = this.guiConfig.hiddenFields;
@@ -31,12 +36,12 @@ export class RecordForm {
31
36
  return f.filter(key => !hidden.has(key));
32
37
  }
33
38
  const tableDef = this.auth.getTableDefinition(this.tableName());
34
- const recordKeys = new Set(Object.keys(this.record()));
39
+ const record = this.working();
40
+ const recordKeys = new Set(Object.keys(record));
35
41
  const editableTimestamps = new Set(
36
42
  this.guiConfig.tableOverrides?.[this.tableName()]?.editableTimestamps ?? []
37
43
  );
38
44
  if (tableDef && tableDef.Columns && tableDef.Columns.length > 0) {
39
- const record = this.record();
40
45
  return tableDef.Columns
41
46
  .filter(col => !(col.HasDefault && col.DataType === 'timestamp'
42
47
  && !editableTimestamps.has(col.PascalName)))
@@ -55,15 +60,25 @@ export class RecordForm {
55
60
  return [...recordKeys].filter(key => !hidden.has(key));
56
61
  });
57
62
 
58
- isArray(value: any): boolean {
63
+ isArray(value: unknown): boolean {
59
64
  return Array.isArray(value);
60
65
  }
61
66
 
62
- private isEmpty(value: any): boolean {
67
+ private isEmpty(value: unknown): boolean {
63
68
  return value === null || value === undefined || value === '';
64
69
  }
65
70
 
66
- onRecordUpdate(updates: {[key: string]: any}) {
67
- Object.assign(this.record(), updates);
71
+ onFieldChange(key: string, value: unknown) {
72
+ this.applyUpdates({[key]: value});
73
+ }
74
+
75
+ onRecordUpdate(updates: {[key: string]: unknown}) {
76
+ this.applyUpdates(updates);
77
+ }
78
+
79
+ private applyUpdates(updates: {[key: string]: unknown}) {
80
+ const updated = {...this.working(), ...updates};
81
+ this.working.set(updated);
82
+ this.recordChange.emit(updated);
68
83
  }
69
84
  }
@@ -1,6 +1,6 @@
1
1
  <h1 mat-dialog-title class="table-form-title">
2
2
  <span class="table-form-title-text">
3
- @if (isNew) {
3
+ @if (isNew()) {
4
4
  New {{ getCaption() }} Record
5
5
  } @else {
6
6
  {{ getCaption() }} Details
@@ -11,7 +11,7 @@
11
11
  </button>
12
12
  </h1>
13
13
  <div mat-dialog-content>
14
- <sail-record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></sail-record-form>
14
+ <sail-record-form [tableName]="tableName()" [record]="editableRecord()" (recordChange)="editableRecord.set($event)" [isReadOnly]="isReadOnlyFn"></sail-record-form>
15
15
  </div>
16
16
  <div mat-dialog-actions align="end">
17
17
  <button matButton="elevated" class="primary" type="submit" (click)="onSave()">Save</button>
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from "@angular/core";
1
+ import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from "@angular/core";
2
2
  import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle } from "@angular/material/dialog";
3
3
  import { BaseForm } from "../abstract/base_form";
4
4
  import { RecordForm } from "./form_record";
@@ -6,7 +6,7 @@ import { MatButtonModule } from "@angular/material/button";
6
6
  import { MatIconModule } from "@angular/material/icon";
7
7
 
8
8
  interface TableFormDialogData {
9
- record: {[key: string]: any};
9
+ record: {[key: string]: unknown};
10
10
  tableName: string;
11
11
  isNew?: boolean;
12
12
  readOnlyColumns?: string[];
@@ -28,6 +28,7 @@ interface TableFormDialogData {
28
28
  ]
29
29
  })
30
30
  export class TableForm extends BaseForm {
31
+ override readonly tableName = signal('');
31
32
  dialogRef = inject(MatDialogRef<TableForm>);
32
33
  data = inject<TableFormDialogData>(MAT_DIALOG_DATA);
33
34
 
@@ -35,9 +36,9 @@ export class TableForm extends BaseForm {
35
36
 
36
37
  constructor() {
37
38
  super();
38
- this.editableRecord = {...this.data.record};
39
+ this.editableRecord.set({...this.data.record});
39
40
  this.tableName.set(this.data.tableName);
40
- this.isNew = !!this.data.isNew;
41
+ this.isNew.set(!!this.data.isNew);
41
42
  this.readOnlyColumns = this.data.readOnlyColumns ?? [];
42
43
  }
43
44
 
@@ -47,7 +48,7 @@ export class TableForm extends BaseForm {
47
48
  }
48
49
 
49
50
  onSave(): void {
50
- const result = {...this.editableRecord};
51
+ const result = {...this.editableRecord()};
51
52
  this.readyToSave(result);
52
53
  this.dialogRef.close(result);
53
54
  }