@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.
- package/README.md +36 -3
- package/package.json +5 -1
- package/src/component/abstract/base_async.ts +9 -11
- package/src/component/abstract/base_form.ts +6 -5
- package/src/component/abstract/base_table.ts +131 -58
- package/src/component/abstract/base_view.ts +43 -27
- package/src/component/account/profile_editor.ts +2 -2
- package/src/component/billing/payment_methods.ts +1 -2
- package/src/component/billing/price_selector.html +2 -1
- package/src/component/billing/price_selector.ts +28 -13
- package/src/component/billing/trial_banner.ts +7 -1
- package/src/component/billing/usage_meter.ts +2 -2
- package/src/component/dashboard/data_card.html +1 -1
- package/src/component/dashboard/entity_selector.ts +21 -23
- package/src/component/form/form_field.html +6 -5
- package/src/component/form/form_field.ts +39 -21
- package/src/component/form/form_record.html +3 -3
- package/src/component/form/form_record.ts +23 -8
- package/src/component/form/form_table.html +2 -2
- package/src/component/form/form_table.ts +6 -5
- package/src/component/login/account_deletion.ts +6 -6
- package/src/component/login/chpass_component.html +4 -4
- package/src/component/login/chpass_component.ts +0 -1
- package/src/component/login/confirm_chpass_component.html +4 -4
- package/src/component/login/confirm_chpass_component.ts +1 -4
- package/src/component/login/consent_gate.html +6 -6
- package/src/component/login/consent_gate.ts +17 -24
- package/src/component/login/login_component.html +7 -4
- package/src/component/login/login_component.ts +9 -5
- package/src/component/login/otp_input.html +13 -5
- package/src/component/login/otp_input.ts +34 -27
- package/src/component/login/register_component.html +31 -14
- package/src/component/login/register_component.ts +80 -89
- package/src/component/login/social_login.html +2 -2
- package/src/component/login/social_login.ts +20 -13
- package/src/component/navigation/navigation.html +2 -2
- package/src/component/navigation/navigation.ts +2 -0
- package/src/component/oauth_connection/oauth_connection_panel.html +68 -0
- package/src/component/oauth_connection/oauth_connection_panel.ts +252 -0
- package/src/component/payment/user_payment_methods.html +2 -2
- package/src/component/payment/user_payment_methods.ts +12 -23
- package/src/component/payout/bank_info_form.html +4 -4
- package/src/component/payout/bank_info_form.ts +22 -26
- package/src/component/payout/provider_onboarding.html +5 -5
- package/src/component/payout/provider_onboarding.ts +28 -48
- package/src/component/security/trusted_devices.html +1 -1
- package/src/component/security/trusted_devices.ts +0 -1
- package/src/component/security/twofactor_setup.html +8 -4
- package/src/component/security/twofactor_setup.ts +21 -3
- package/src/component/security/twofactor_verify.html +2 -1
- package/src/component/security/twofactor_verify.ts +2 -8
- package/src/component/table/table_detail.html +15 -14
- package/src/component/table/table_detail.ts +62 -41
- package/src/component/table/table_edit.html +15 -11
- package/src/component/table/table_edit.ts +94 -67
- package/src/component/table/table_list.html +45 -12
- package/src/component/table/table_list.ts +98 -26
- package/src/component/table/table_lookup.html +11 -8
- package/src/component/table/table_lookup.ts +45 -37
- package/src/component/table/table_report.html +2 -2
- package/src/component/table/table_report.ts +37 -31
- package/src/component/table/table_search.html +4 -4
- package/src/component/table/table_search.ts +36 -45
- package/src/config.ts +1 -1
- package/src/index.ts +4 -0
- package/src/model/appdata.ts +5 -9
- package/src/model/common.ts +27 -27
- package/src/model/decorator.ts +25 -21
- package/src/model/oauth_connection.ts +50 -0
- package/src/service/api-response.interceptor.ts +4 -1
- package/src/service/auth.interceptor.ts +27 -7
- package/src/service/auth.service.ts +188 -134
- package/src/service/label.service.ts +6 -5
- package/src/service/oauth_connection.service.ts +56 -0
- package/src/service/rest_service.ts +57 -13
- package/src/service/rest_url.ts +23 -7
- package/src/service/script_loader.ts +12 -2
- package/src/util/errors.ts +20 -0
- 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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
15
|
+
protected readonly hasLinkPermission = computed(() => this.canUpdate() || this.canRead());
|
|
16
16
|
|
|
17
|
-
|
|
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(
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
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
|
-
)
|
|
25
|
-
this.
|
|
28
|
+
) columns.push('actions');
|
|
29
|
+
this.displayedColumns.set(columns);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
isLinkColumn(column: string): boolean {
|
|
29
|
-
|
|
33
|
+
const columns = this.displayedColumns();
|
|
34
|
+
return (columns.length > 0 && columns[0] === column && this.hasLinkPermission());
|
|
30
35
|
}
|
|
36
|
+
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
66
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
|
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] ??
|
|
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 -
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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 :
|
|
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]="
|
|
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]="
|
|
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<
|
|
36
|
-
readonly valueChange = output<
|
|
37
|
-
readonly recordUpdate = output<{[key: string]:
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
157
|
+
onSelectChange(event: MatSelectChange) {
|
|
158
|
+
this.valueChange.emit(event.value);
|
|
145
159
|
}
|
|
146
160
|
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
if (fk
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
|
3
|
+
@if (!isArray(working()[key])) {
|
|
4
4
|
<sail-dynamic-field
|
|
5
5
|
[tableName]="tableName()"
|
|
6
|
-
[value]="
|
|
7
|
-
(valueChange)="
|
|
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]:
|
|
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
|
|
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:
|
|
63
|
+
isArray(value: unknown): boolean {
|
|
59
64
|
return Array.isArray(value);
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
private isEmpty(value:
|
|
67
|
+
private isEmpty(value: unknown): boolean {
|
|
63
68
|
return value === null || value === undefined || value === '';
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
|
|
67
|
-
|
|
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]="
|
|
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]:
|
|
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
|
|
39
|
+
this.editableRecord.set({...this.data.record});
|
|
39
40
|
this.tableName.set(this.data.tableName);
|
|
40
|
-
this.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
|
}
|