@nauticana/sail 0.8.0 → 0.8.1
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 +120 -15
- package/package.json +2 -2
- package/src/component/abstract/base_async.ts +8 -2
- package/src/component/abstract/base_table.ts +94 -35
- package/src/component/abstract/base_view.ts +28 -41
- package/src/component/billing/checkout_button.ts +1 -1
- package/src/component/billing/payment_methods.ts +1 -1
- package/src/component/billing/plan_selector.ts +1 -1
- package/src/component/form/form_field.ts +8 -11
- package/src/component/form/form_record.html +2 -2
- package/src/component/form/form_record.ts +1 -1
- package/src/component/form/form_table.html +1 -1
- package/src/component/form/form_table.ts +1 -1
- package/src/component/login/account_deletion.ts +1 -1
- package/src/component/login/chpass_component.ts +11 -16
- package/src/component/login/confirm_chpass_component.ts +9 -15
- package/src/component/login/confirm_register_component.ts +9 -15
- package/src/component/login/consent_gate.ts +1 -1
- package/src/component/login/login_component.html +1 -1
- package/src/component/login/login_component.ts +1 -1
- package/src/component/login/otp_input.ts +1 -1
- package/src/component/login/register_component.ts +1 -1
- package/src/component/login/social_login.ts +1 -1
- package/src/component/navigation/navigation.ts +10 -11
- package/src/component/security/trusted_devices.ts +1 -1
- package/src/component/security/twofactor_setup.ts +18 -26
- package/src/component/security/twofactor_verify.ts +10 -12
- package/src/component/table/table_detail.html +4 -4
- package/src/component/table/table_detail.ts +22 -34
- package/src/component/table/table_edit.html +13 -3
- package/src/component/table/table_edit.ts +17 -17
- package/src/component/table/table_list.ts +18 -52
- package/src/component/table/table_lookup.html +1 -1
- package/src/component/table/table_lookup.ts +1 -3
- package/src/component/table/table_report.ts +11 -14
- package/src/component/table/table_search.html +1 -1
- package/src/component/table/table_search.ts +14 -38
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +2 -2
- package/src/model/common.ts +39 -1
- package/src/service/auth.service.ts +26 -26
- package/src/service/base_rest.service.ts +20 -0
- package/src/service/billing.service.ts +3 -9
- package/src/service/payout.service.ts +3 -9
- package/src/service/rest_service.ts +5 -5
- package/src/service/user_payment_method.service.ts +4 -8
- package/src/util/text.ts +19 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, effect, inject, input, OnInit, ViewEncapsulation } from "@angular/core";
|
|
1
|
+
import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { DynamicField } from "../form/form_field";
|
|
3
3
|
import { MatButtonModule } from "@angular/material/button";
|
|
4
4
|
import { BaseForm } from "../abstract/base_form";
|
|
5
5
|
import { MatDialog } from "@angular/material/dialog";
|
|
6
6
|
import { TableForm } from "../form/form_table";
|
|
7
7
|
import { MatIconModule } from "@angular/material/icon";
|
|
8
|
-
import { BackendService } from "../../service/rest_service";
|
|
9
8
|
import { TableAction } from "../../model/appdata";
|
|
9
|
+
import { OpCode } from "../../model/common";
|
|
10
10
|
|
|
11
11
|
@Component({
|
|
12
|
-
selector: 'table-detail',
|
|
12
|
+
selector: 'sail-table-detail',
|
|
13
13
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
14
|
encapsulation: ViewEncapsulation.None,
|
|
15
15
|
templateUrl: './table_detail.html',
|
|
@@ -20,13 +20,15 @@ import { TableAction } from "../../model/appdata";
|
|
|
20
20
|
],
|
|
21
21
|
})
|
|
22
22
|
export class TableDetail extends BaseForm implements OnInit {
|
|
23
|
-
readonly tableNameInput = input('', { alias: 'tableName' });
|
|
24
|
-
readonly recordsInput
|
|
23
|
+
protected readonly tableNameInput = input('', { alias: 'tableName' });
|
|
24
|
+
protected readonly recordsInput = input<any[]>([], { alias: 'records' });
|
|
25
25
|
readonly parentTableName = input('');
|
|
26
26
|
readonly parentRecord = input<any>({});
|
|
27
27
|
/** When true, the parent view is in read-only mode — disable all child row editing. */
|
|
28
28
|
readonly parentReadOnly = input(false);
|
|
29
29
|
|
|
30
|
+
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
31
|
+
|
|
30
32
|
records: any[] = [];
|
|
31
33
|
editingRecord: any = null;
|
|
32
34
|
originalRecord: any = null;
|
|
@@ -34,39 +36,25 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
34
36
|
fkColumns: string[] = [];
|
|
35
37
|
|
|
36
38
|
private readonly dialog = inject(MatDialog);
|
|
37
|
-
private readonly backendService = inject(BackendService);
|
|
38
39
|
|
|
39
40
|
constructor() {
|
|
40
41
|
super();
|
|
41
|
-
|
|
42
|
+
// records is a writable array (push/splice in event handlers); keep it in sync with the input
|
|
43
|
+
// via effect rather than linkedSignal so mutations work on the consumer's array reference.
|
|
42
44
|
effect(() => { this.records = this.recordsInput(); });
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* the resolved URL directly — no need to round-trip through the
|
|
50
|
-
* parent form.
|
|
48
|
+
* Per-row actions require the record to be server-side (not 'I'
|
|
49
|
+
* inserted-locally-not-saved); the keel action POST would hit a record
|
|
50
|
+
* that doesn't exist yet. Table-level actions are always allowed.
|
|
51
51
|
*/
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
if (action.recordSpecific && record && record[this.config.opField] === 'I') {
|
|
52
|
+
protected override beforeExecuteAction(action: TableAction, record?: Record<string, unknown>): boolean {
|
|
53
|
+
if (action.recordSpecific && record && record[this.config.opField] === OpCode.Insert) {
|
|
58
54
|
alert('Save the record first before running this action.');
|
|
59
|
-
return;
|
|
55
|
+
return false;
|
|
60
56
|
}
|
|
61
|
-
|
|
62
|
-
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
63
|
-
this.backendService.executeAction(action.method, body).subscribe({
|
|
64
|
-
next: () => {/* parent form re-fetches on its own save */},
|
|
65
|
-
error: (err) => {
|
|
66
|
-
console.error(`Action ${action.action} failed`, err);
|
|
67
|
-
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
68
|
-
},
|
|
69
|
-
});
|
|
57
|
+
return true;
|
|
70
58
|
}
|
|
71
59
|
|
|
72
60
|
override canCreate() { return !this.parentReadOnly() && super.canCreate(); }
|
|
@@ -106,7 +94,7 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
106
94
|
|
|
107
95
|
addRecord() {
|
|
108
96
|
const newRecord = this.emptyRecord();
|
|
109
|
-
newRecord[this.config.opField] =
|
|
97
|
+
newRecord[this.config.opField] = OpCode.Insert;
|
|
110
98
|
this.initializeForeignKeys(newRecord, this.parentTableName(), this.parentRecord());
|
|
111
99
|
|
|
112
100
|
if (this.displayedColumns.length > 6) {
|
|
@@ -124,7 +112,7 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
124
112
|
this.openEditDialog(record, false, this.fkColumns);
|
|
125
113
|
} else {
|
|
126
114
|
this.editingRecord = record;
|
|
127
|
-
this.isNew = record[this.config.opField] ===
|
|
115
|
+
this.isNew = record[this.config.opField] === OpCode.Insert;
|
|
128
116
|
this.originalRecord = {...record};
|
|
129
117
|
this.formatRecordTimeStamp(record);
|
|
130
118
|
}
|
|
@@ -152,19 +140,19 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
152
140
|
}
|
|
153
141
|
|
|
154
142
|
deleteRow(record: any) {
|
|
155
|
-
if (record[this.config.opField] ===
|
|
143
|
+
if (record[this.config.opField] === OpCode.Insert) {
|
|
156
144
|
const index = this.records.indexOf(record);
|
|
157
145
|
if (index > -1) {
|
|
158
146
|
this.records.splice(index, 1);
|
|
159
147
|
}
|
|
160
148
|
} else {
|
|
161
|
-
record[this.config.opField] =
|
|
149
|
+
record[this.config.opField] = OpCode.Delete;
|
|
162
150
|
}
|
|
163
151
|
}
|
|
164
152
|
|
|
165
153
|
unDeleteRow(record: any) {
|
|
166
|
-
if (record[this.config.opField] ===
|
|
167
|
-
record[this.config.opField] =
|
|
154
|
+
if (record[this.config.opField] === OpCode.Delete) {
|
|
155
|
+
record[this.config.opField] = OpCode.Update;
|
|
168
156
|
}
|
|
169
157
|
}
|
|
170
158
|
}
|
|
@@ -28,13 +28,23 @@
|
|
|
28
28
|
@if (canDelete()) {
|
|
29
29
|
<button matButton class="warn" (click)="onDelete()">Delete</button>
|
|
30
30
|
}
|
|
31
|
+
@for (action of getActions(true); track action.action) {
|
|
32
|
+
@if (canExecuteAction(action)) {
|
|
33
|
+
<button matButton="outlined" type="button"
|
|
34
|
+
[title]="action.caption"
|
|
35
|
+
(click)="executeAction(action, record())">
|
|
36
|
+
@if (action.icon) { <mat-icon>{{ action.icon }}</mat-icon> }
|
|
37
|
+
{{ action.caption }}
|
|
38
|
+
</button>
|
|
39
|
+
}
|
|
40
|
+
}
|
|
31
41
|
} @else {
|
|
32
42
|
<button matButton="elevated" class="primary" (click)="onSave()">Save</button>
|
|
33
43
|
<button matButton (click)="onCancel()">Cancel</button>
|
|
34
44
|
}
|
|
35
45
|
</div>
|
|
36
46
|
|
|
37
|
-
<record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></record-form>
|
|
47
|
+
<sail-record-form [tableName]="tableName()" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></sail-record-form>
|
|
38
48
|
|
|
39
49
|
@if (tabCount > 0) {
|
|
40
50
|
<mat-tab-group>
|
|
@@ -42,13 +52,13 @@
|
|
|
42
52
|
@if (cacheService.canRead(getTableName(key))) {
|
|
43
53
|
<mat-tab [label]="key">
|
|
44
54
|
<div class="tab-content">
|
|
45
|
-
<table-detail
|
|
55
|
+
<sail-table-detail
|
|
46
56
|
[records]="editableRecord[key]"
|
|
47
57
|
[tableName]="getTableName(key)"
|
|
48
58
|
[parentTableName]="tableName()"
|
|
49
59
|
[parentRecord]="editableRecord"
|
|
50
60
|
[parentReadOnly]="isReadOnlyMode">
|
|
51
|
-
</table-detail>
|
|
61
|
+
</sail-table-detail>
|
|
52
62
|
</div>
|
|
53
63
|
</mat-tab>
|
|
54
64
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, ChangeDetectorRef, Component,
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { BaseForm } from "../abstract/base_form";
|
|
3
3
|
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
|
|
4
4
|
import { MatTabsModule } from "@angular/material/tabs";
|
|
5
5
|
import { TableDetail } from "./table_detail";
|
|
6
|
-
import { BackendService } from "../../service/rest_service";
|
|
7
6
|
import { RecordForm } from "../form/form_record";
|
|
8
7
|
import { MatButtonModule } from "@angular/material/button";
|
|
9
8
|
import { MatIconModule } from "@angular/material/icon";
|
|
9
|
+
import { OpCode } from "../../model/common";
|
|
10
10
|
|
|
11
11
|
@Component({
|
|
12
|
-
selector: 'table-edit',
|
|
12
|
+
selector: 'sail-table-edit',
|
|
13
13
|
templateUrl: './table_edit.html',
|
|
14
14
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
15
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -22,13 +22,15 @@ import { MatIconModule } from "@angular/material/icon";
|
|
|
22
22
|
]
|
|
23
23
|
})
|
|
24
24
|
export class TableEdit extends BaseForm implements OnInit {
|
|
25
|
-
readonly recordInput
|
|
26
|
-
readonly tableNameInput = input('',
|
|
27
|
-
readonly apiNameInput
|
|
28
|
-
|
|
29
|
-
readonly
|
|
25
|
+
protected readonly recordInput = input<any>({}, { alias: 'record' });
|
|
26
|
+
protected readonly tableNameInput = input('', { alias: 'tableName' });
|
|
27
|
+
protected readonly apiNameInput = input('', { alias: 'apiName' });
|
|
28
|
+
|
|
29
|
+
readonly record = linkedSignal<any, any>({ source: () => this.recordInput(), computation: (v, p) => v ?? p?.value ?? {} });
|
|
30
|
+
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
31
|
+
readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
32
|
+
|
|
30
33
|
isReadOnlyMode = true;
|
|
31
|
-
private readonly backendService = inject(BackendService);
|
|
32
34
|
private readonly dialogRef = inject(MatDialogRef<TableEdit>, {optional: true});
|
|
33
35
|
private readonly dialogData = inject<{record?: any; tableName?: string; apiName?: string; isNew?: boolean} | null>(MAT_DIALOG_DATA, {optional: true});
|
|
34
36
|
private readonly cdr = inject(ChangeDetectorRef);
|
|
@@ -39,13 +41,6 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
39
41
|
/** Snapshot taken when the user enters edit mode; used for dirty-tracking on save. */
|
|
40
42
|
private originalSnapshot: any = null;
|
|
41
43
|
|
|
42
|
-
constructor() {
|
|
43
|
-
super();
|
|
44
|
-
effect(() => { const v = this.recordInput(); if (v) this.record.set(v); });
|
|
45
|
-
effect(() => { const v = this.tableNameInput(); if (v) this.tableName.set(v); });
|
|
46
|
-
effect(() => { const v = this.apiNameInput(); if (v) this.apiName.set(v); });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
44
|
ngOnInit(): void {
|
|
50
45
|
if (this.dialogData) {
|
|
51
46
|
this.record.set(this.dialogData.record ?? {});
|
|
@@ -79,6 +74,11 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
79
74
|
}
|
|
80
75
|
}
|
|
81
76
|
|
|
77
|
+
/** Re-fetch the row from the backend after a server-side mutation (TableAction success, etc.). */
|
|
78
|
+
protected override onActionSuccess(): void {
|
|
79
|
+
if (!this.isNew) this.fetchFullRecord();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
82
|
private fetchFullRecord() {
|
|
83
83
|
const rec = this.record();
|
|
84
84
|
this.editableRecord = {...rec};
|
|
@@ -149,7 +149,7 @@ export class TableEdit extends BaseForm implements OnInit {
|
|
|
149
149
|
onDelete() {
|
|
150
150
|
if (confirm('Are you sure you want to delete this record?')) {
|
|
151
151
|
const result = {...this.editableRecord};
|
|
152
|
-
result[this.config.opField] =
|
|
152
|
+
result[this.config.opField] = OpCode.Delete;
|
|
153
153
|
this.backendService.post(this.apiName(), result).subscribe({
|
|
154
154
|
next: () => {
|
|
155
155
|
this.isReadOnlyMode = true;
|
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef,
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
3
3
|
import { ActivatedRoute, Params } from "@angular/router";
|
|
4
4
|
import { BaseView } from "../abstract/base_view";
|
|
5
5
|
import { TableEdit } from "./table_edit";
|
|
6
|
-
import { BackendService } from "../../service/rest_service";
|
|
7
|
-
import { TableAction } from "../../model/appdata";
|
|
8
6
|
import { MatButtonModule } from "@angular/material/button";
|
|
9
7
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
10
8
|
import { MatIconModule } from "@angular/material/icon";
|
|
11
9
|
|
|
12
10
|
@Component({
|
|
13
|
-
selector: 'table-list',
|
|
11
|
+
selector: 'sail-table-list',
|
|
14
12
|
templateUrl: './table_list.html',
|
|
15
13
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
14
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -18,22 +16,21 @@ import { MatIconModule } from "@angular/material/icon";
|
|
|
18
16
|
})
|
|
19
17
|
export class TableList extends BaseView implements OnInit {
|
|
20
18
|
override dialogWidth = '400px';
|
|
21
|
-
readonly tableNameInput
|
|
22
|
-
readonly apiNameInput
|
|
23
|
-
readonly dialogComponentInput = input<any>(undefined, { alias: 'dialogComponent' });
|
|
19
|
+
protected readonly tableNameInput = input('', { alias: 'tableName' });
|
|
20
|
+
protected readonly apiNameInput = input('', { alias: 'apiName' });
|
|
21
|
+
protected readonly dialogComponentInput = input<any>(undefined, { alias: 'dialogComponent' });
|
|
22
|
+
|
|
23
|
+
// linkedSignal: defaults to the consumer's input, but ngOnInit / route data
|
|
24
|
+
// can override with `.set()`. The `computation` preserves the last
|
|
25
|
+
// non-empty value when the input transiently becomes empty.
|
|
26
|
+
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
27
|
+
override readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
28
|
+
override readonly dialogComponent = linkedSignal<any, any>({ source: () => this.dialogComponentInput(), computation: (v, p) => v ?? p?.value });
|
|
24
29
|
|
|
25
30
|
private readonly route = inject(ActivatedRoute);
|
|
26
|
-
private readonly backendService = inject(BackendService);
|
|
27
31
|
private readonly cdr = inject(ChangeDetectorRef);
|
|
28
32
|
private readonly destroyRef = inject(DestroyRef);
|
|
29
33
|
|
|
30
|
-
constructor() {
|
|
31
|
-
super();
|
|
32
|
-
effect(() => { const v = this.tableNameInput(); if (v) this.tableName.set(v); });
|
|
33
|
-
effect(() => { const v = this.apiNameInput(); if (v) this.apiName.set(v); });
|
|
34
|
-
effect(() => { const v = this.dialogComponentInput();if (v) this.dialogComponent.set(v); });
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
ngOnInit() {
|
|
38
35
|
const data = this.route.snapshot.data;
|
|
39
36
|
if (!this.tableName() && data['tableName']) this.tableName.set(data['tableName']);
|
|
@@ -43,10 +40,7 @@ export class TableList extends BaseView implements OnInit {
|
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
protected handleDelete(record: {[key: string]: any}): void {
|
|
46
|
-
if (!this.canDelete())
|
|
47
|
-
alert('Missing authorization to delete records');
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
43
|
+
if (!this.requireAuth(() => this.canDelete(), 'delete')) return;
|
|
50
44
|
if (confirm('Are you sure you want to delete this record?')) {
|
|
51
45
|
const keyFilter = this.getKeyFilters(record);
|
|
52
46
|
this.backendService.delete(this.apiName(), keyFilter).subscribe({
|
|
@@ -57,14 +51,7 @@ export class TableList extends BaseView implements OnInit {
|
|
|
57
51
|
}
|
|
58
52
|
|
|
59
53
|
protected handleUpdate(result: {[key: string]: any}, isNew: boolean, _original: {[key: string]: any}): void {
|
|
60
|
-
if (isNew
|
|
61
|
-
alert('Missing authorization to create records');
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (!isNew && !this.canUpdate()) {
|
|
65
|
-
alert('Missing authorization to update records');
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
54
|
+
if (!this.requireAuth(() => isNew ? this.canCreate() : this.canUpdate(), isNew ? 'create' : 'update')) return;
|
|
68
55
|
if (confirm('Are you sure you want to save this record?')) {
|
|
69
56
|
this.backendService.post(this.apiName(), result).subscribe({
|
|
70
57
|
next: () => this.fetchRecords(),
|
|
@@ -74,10 +61,7 @@ export class TableList extends BaseView implements OnInit {
|
|
|
74
61
|
}
|
|
75
62
|
|
|
76
63
|
fetchRecords() {
|
|
77
|
-
if (!this.canRead())
|
|
78
|
-
alert('Missing authorization to read records');
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
64
|
+
if (!this.requireAuth(() => this.canRead(), 'read')) return;
|
|
81
65
|
this.backendService.list<{[key: string]: any}>(this.apiName(), this.searchTerms).subscribe({
|
|
82
66
|
next: (records) => {
|
|
83
67
|
this.records = records;
|
|
@@ -88,27 +72,9 @@ export class TableList extends BaseView implements OnInit {
|
|
|
88
72
|
});
|
|
89
73
|
}
|
|
90
74
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
* For table-level actions, `record` is undefined and the body is empty.
|
|
95
|
-
* Refreshes the row list on success — most actions mutate visible
|
|
96
|
-
* state (set_default flipping defaults, etc.).
|
|
97
|
-
*/
|
|
98
|
-
executeAction(action: TableAction, record?: {[key: string]: unknown}): void {
|
|
99
|
-
if (!this.canExecuteAction(action)) {
|
|
100
|
-
alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
104
|
-
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
105
|
-
this.backendService.executeAction(action.method, body).subscribe({
|
|
106
|
-
next: () => this.fetchRecords(),
|
|
107
|
-
error: (err) => {
|
|
108
|
-
console.error(`Action ${action.action} failed`, err);
|
|
109
|
-
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
110
|
-
},
|
|
111
|
-
});
|
|
75
|
+
/** Refresh the row list on successful action — most actions mutate visible state (set_default flipping defaults, etc.). */
|
|
76
|
+
protected override onActionSuccess(): void {
|
|
77
|
+
this.fetchRecords();
|
|
112
78
|
}
|
|
113
79
|
|
|
114
80
|
handleQueryParams(params: Params) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<h2 mat-dialog-title>{{ getCaption() }} Search</h2>
|
|
2
2
|
<div mat-dialog-content>
|
|
3
|
-
<record-form [tableName]="tableName()" [record]="searchRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></record-form>
|
|
3
|
+
<sail-record-form [tableName]="tableName()" [record]="searchRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></sail-record-form>
|
|
4
4
|
<div class="search-actions">
|
|
5
5
|
<button matButton="elevated" class="primary" (click)="onSearch()">Search</button>
|
|
6
6
|
<button matButton (click)="onClear()">Clear</button>
|
|
@@ -4,10 +4,9 @@ import { MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent, Ma
|
|
|
4
4
|
import { MatIconModule } from "@angular/material/icon";
|
|
5
5
|
import { RecordForm } from "../form/form_record";
|
|
6
6
|
import { BaseView } from "../abstract/base_view";
|
|
7
|
-
import { BackendService } from "../../service/rest_service";
|
|
8
7
|
|
|
9
8
|
@Component({
|
|
10
|
-
selector: 'table-lookup',
|
|
9
|
+
selector: 'sail-table-lookup',
|
|
11
10
|
templateUrl: './table_lookup.html',
|
|
12
11
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
12
|
encapsulation: ViewEncapsulation.None,
|
|
@@ -25,7 +24,6 @@ export class TableLookup extends BaseView implements OnInit {
|
|
|
25
24
|
searchRecord: any = {};
|
|
26
25
|
searchColumns: string[] = [];
|
|
27
26
|
|
|
28
|
-
private readonly backendService = inject(BackendService);
|
|
29
27
|
private readonly dialogRef = inject(MatDialogRef<TableLookup>);
|
|
30
28
|
private readonly data = inject<{tableName?: string; apiName?: string}>(MAT_DIALOG_DATA);
|
|
31
29
|
protected readonly cdr = inject(ChangeDetectorRef);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from "@angular/core";
|
|
2
|
-
import {
|
|
2
|
+
import { HttpParams } from "@angular/common/http";
|
|
3
3
|
import { FormsModule } from "@angular/forms";
|
|
4
4
|
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
5
5
|
import { MatFormFieldModule } from "@angular/material/form-field";
|
|
@@ -7,12 +7,13 @@ import { MatInputModule } from "@angular/material/input";
|
|
|
7
7
|
import { MatSelectModule } from "@angular/material/select";
|
|
8
8
|
import { MatButtonModule } from "@angular/material/button";
|
|
9
9
|
import { BaseAuthService } from "../../service/auth.service";
|
|
10
|
-
import {
|
|
10
|
+
import { BaseRestService } from "../../service/base_rest.service";
|
|
11
11
|
import { ConstantValue } from "../../model/common";
|
|
12
12
|
import { ReportParam } from "../../model/appdata";
|
|
13
|
+
import { titleCase } from "../../util/text";
|
|
13
14
|
|
|
14
15
|
@Component({
|
|
15
|
-
selector: 'table-report',
|
|
16
|
+
selector: 'sail-table-report',
|
|
16
17
|
templateUrl: './table_report.html',
|
|
17
18
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
18
19
|
imports: [
|
|
@@ -24,11 +25,10 @@ import { ReportParam } from "../../model/appdata";
|
|
|
24
25
|
MatButtonModule,
|
|
25
26
|
],
|
|
26
27
|
})
|
|
27
|
-
export class TableReport {
|
|
28
|
+
export class TableReport extends BaseRestService {
|
|
28
29
|
readonly apiName = input('');
|
|
29
30
|
readonly tableName = input('');
|
|
30
31
|
|
|
31
|
-
private readonly http = inject(HttpClient);
|
|
32
32
|
private readonly auth = inject(BaseAuthService);
|
|
33
33
|
|
|
34
34
|
readonly records = signal<Record<string, unknown>[]>([]);
|
|
@@ -49,7 +49,7 @@ export class TableReport {
|
|
|
49
49
|
*/
|
|
50
50
|
readonly reportTitle = computed(() => {
|
|
51
51
|
const reportId = this.apiName().split('/').pop() ?? '';
|
|
52
|
-
return this.auth.getReport(reportId)?.Description ||
|
|
52
|
+
return this.auth.getReport(reportId)?.Description || titleCase(this.tableName());
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
/**
|
|
@@ -65,6 +65,7 @@ export class TableReport {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
constructor() {
|
|
68
|
+
super();
|
|
68
69
|
effect(() => {
|
|
69
70
|
const api = this.apiName();
|
|
70
71
|
if (!api) return;
|
|
@@ -133,8 +134,7 @@ export class TableReport {
|
|
|
133
134
|
httpParams = httpParams.set(k, v);
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
|
-
|
|
137
|
-
this.http.get<Record<string, unknown>[]>(url, { params: httpParams }).subscribe({
|
|
137
|
+
this.http.get<Record<string, unknown>[]>(this.url('/api/' + api), { params: httpParams }).subscribe({
|
|
138
138
|
next: (rows) => {
|
|
139
139
|
this.records.set(rows ?? []);
|
|
140
140
|
if (rows?.length) {
|
|
@@ -145,14 +145,11 @@ export class TableReport {
|
|
|
145
145
|
this.errorMessage.set('');
|
|
146
146
|
},
|
|
147
147
|
error: (err) => {
|
|
148
|
-
this.errorMessage.set(err?.error ?? 'Failed to load report data.');
|
|
148
|
+
this.errorMessage.set(err?.error?.detail ?? err?.error?.message ?? 'Failed to load report data.');
|
|
149
149
|
},
|
|
150
150
|
});
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
156
|
-
.join(' ');
|
|
157
|
-
}
|
|
153
|
+
/** Re-exported for templates; same as the shared util. */
|
|
154
|
+
titleCase(str: string): string { return titleCase(str); }
|
|
158
155
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
}
|
|
7
7
|
</h1>
|
|
8
8
|
<div class="page-container">
|
|
9
|
-
<record-form [tableName]="tableName()" [record]="editableRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></record-form>
|
|
9
|
+
<sail-record-form [tableName]="tableName()" [record]="editableRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></sail-record-form>
|
|
10
10
|
</div>
|
|
11
11
|
<div class="search-actions">
|
|
12
12
|
<button matButton="elevated" class="primary" (click)="onSearch()">Search</button>
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, ChangeDetectorRef, Component,
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
2
|
import { MatButtonModule } from "@angular/material/button";
|
|
3
3
|
import { MatIconModule } from "@angular/material/icon";
|
|
4
4
|
import { ActivatedRoute, Router } from "@angular/router";
|
|
5
5
|
import { BaseForm } from "../abstract/base_form";
|
|
6
6
|
import { RecordForm } from "../form/form_record";
|
|
7
7
|
import { ApplicationMenu } from "../../model/common";
|
|
8
|
-
import { TableAction } from "../../model/appdata";
|
|
9
|
-
import { BackendService } from "../../service/rest_service";
|
|
10
8
|
|
|
11
9
|
@Component({
|
|
12
|
-
selector: 'table-search',
|
|
10
|
+
selector: 'sail-table-search',
|
|
13
11
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
12
|
encapsulation: ViewEncapsulation.None,
|
|
15
13
|
templateUrl: './table_search.html',
|
|
@@ -20,44 +18,25 @@ import { BackendService } from "../../service/rest_service";
|
|
|
20
18
|
],
|
|
21
19
|
})
|
|
22
20
|
export class TableSearch extends BaseForm implements OnInit {
|
|
23
|
-
readonly tableNameInput
|
|
24
|
-
readonly apiNameInput
|
|
25
|
-
readonly targetRouteInput = input('', { alias: 'targetRoute' });
|
|
26
|
-
readonly apiName = signal('');
|
|
27
|
-
readonly targetRoute = signal('');
|
|
28
|
-
searchColumns: string[] = [];
|
|
21
|
+
protected readonly tableNameInput = input('', { alias: 'tableName' });
|
|
22
|
+
protected readonly apiNameInput = input('', { alias: 'apiName' });
|
|
23
|
+
protected readonly targetRouteInput = input('', { alias: 'targetRoute' });
|
|
29
24
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
25
|
+
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
26
|
+
readonly apiName = linkedSignal<string, string>({ source: () => this.apiNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
27
|
+
readonly targetRoute = linkedSignal<string, string>({ source: () => this.targetRouteInput(), computation: (v, p) => v || p?.value || '' });
|
|
28
|
+
|
|
29
|
+
searchColumns: string[] = [];
|
|
36
30
|
|
|
37
31
|
private readonly router = inject(Router);
|
|
38
32
|
private readonly route = inject(ActivatedRoute);
|
|
39
33
|
private readonly cdr = inject(ChangeDetectorRef);
|
|
40
|
-
private readonly backendService = inject(BackendService);
|
|
41
34
|
|
|
42
35
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* row
|
|
36
|
+
* Search screen surfaces only table-level actions; record-specific actions
|
|
37
|
+
* never appear here (no row context). `executeAction()` is inherited from
|
|
38
|
+
* BaseTable; no per-screen refresh needed since search has no row list.
|
|
46
39
|
*/
|
|
47
|
-
executeAction(action: TableAction): void {
|
|
48
|
-
if (!this.canExecuteAction(action)) {
|
|
49
|
-
alert(`Missing authorization for action ${action.authorityObject}/${action.authorityCheck}`);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
53
|
-
this.backendService.executeAction(action.method, {}).subscribe({
|
|
54
|
-
next: () => {/* no-op — search screen has no list to refresh */},
|
|
55
|
-
error: (err) => {
|
|
56
|
-
console.error(`Action ${action.action} failed`, err);
|
|
57
|
-
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
40
|
|
|
62
41
|
ngOnInit() {
|
|
63
42
|
const data = this.route.snapshot.data;
|
|
@@ -130,10 +109,7 @@ export class TableSearch extends BaseForm implements OnInit {
|
|
|
130
109
|
}
|
|
131
110
|
|
|
132
111
|
onAddRecord() {
|
|
133
|
-
if (!this.canCreate())
|
|
134
|
-
alert('Missing authorization to create records');
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
112
|
+
if (!this.requireAuth(() => this.canCreate(), 'create')) return;
|
|
137
113
|
const targetRoute = this.targetRoute();
|
|
138
114
|
if (!targetRoute) {
|
|
139
115
|
alert('Navigation target not found for this table');
|
package/src/index.ts
CHANGED
package/src/model/appdata.ts
CHANGED
|
@@ -197,7 +197,7 @@ export interface PartnerRegistration {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
// PublicPlan — shape returned by GET /public/plans. Used on the registration
|
|
200
|
-
// page to render the plan picker and to drive `<
|
|
200
|
+
// page to render the plan picker and to drive `<sail-checkout-button>` flows.
|
|
201
201
|
export interface PublicPlan {
|
|
202
202
|
id: string;
|
|
203
203
|
caption: string;
|
|
@@ -206,7 +206,7 @@ export interface PublicPlan {
|
|
|
206
206
|
// Stripe price ID (or provider-equivalent). Optional because keel only
|
|
207
207
|
// populates it when a plan is wired to a billing provider; FREE plans and
|
|
208
208
|
// not-yet-priced tiers leave it empty. Pass straight to
|
|
209
|
-
// `<
|
|
209
|
+
// `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
|
|
210
210
|
priceId?: string;
|
|
211
211
|
}
|
|
212
212
|
|
package/src/model/common.ts
CHANGED
|
@@ -1,7 +1,45 @@
|
|
|
1
1
|
import { IsString, IsNumeric } from './decorator';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* SIUD operation codes that travel on every row through keel's generic CRUD.
|
|
5
|
+
* Sail tags records with these so the backend knows what to do with each row
|
|
6
|
+
* inside a bulk POST. Mirror of keel's `data.SiudOpCode` constants.
|
|
7
|
+
*
|
|
8
|
+
* S = Select-only (default for fetched rows — no change)
|
|
9
|
+
* I = Insert (locally created, not yet persisted)
|
|
10
|
+
* U = Update (existing row, scalar fields changed)
|
|
11
|
+
* D = Delete (existing row, mark for removal)
|
|
12
|
+
* R = Read-only (existing row, no change after dirty-check)
|
|
13
|
+
*
|
|
14
|
+
* Always reference via `OpCode.Insert` etc. — never the bare string. The
|
|
15
|
+
* `OpCodeValue` union type lets TS catch typos at compile time.
|
|
16
|
+
*/
|
|
17
|
+
export const OpCode = {
|
|
18
|
+
Select: 'S',
|
|
19
|
+
Insert: 'I',
|
|
20
|
+
Update: 'U',
|
|
21
|
+
Delete: 'D',
|
|
22
|
+
Readonly: 'R',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type OpCodeValue = typeof OpCode[keyof typeof OpCode];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lookup-style values on `TableColumn.LookupStyle` — controls how the lookup
|
|
29
|
+
* renders in the form. Mirror of keel's basis `column.lookup_style` enum.
|
|
30
|
+
*
|
|
31
|
+
* D = Dropdown (constant_value rows preloaded)
|
|
32
|
+
* S = Search (TableLookup dialog with full search UI)
|
|
33
|
+
*/
|
|
34
|
+
export const LookupStyle = {
|
|
35
|
+
Dropdown: 'D',
|
|
36
|
+
Search: 'S',
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
export type LookupStyleValue = typeof LookupStyle[keyof typeof LookupStyle];
|
|
40
|
+
|
|
3
41
|
export class SiudAction {
|
|
4
|
-
op_code =
|
|
42
|
+
op_code: OpCodeValue = OpCode.Select;
|
|
5
43
|
}
|
|
6
44
|
|
|
7
45
|
export class UserAccountPolicy extends SiudAction {
|