@nauticana/sail 0.5.0
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/LICENSE +201 -0
- package/README.md +741 -0
- package/package.json +58 -0
- package/src/component/abstract/base_async.ts +49 -0
- package/src/component/abstract/base_form.ts +11 -0
- package/src/component/abstract/base_table.ts +320 -0
- package/src/component/abstract/base_view.ts +93 -0
- package/src/component/billing/checkout_button.html +10 -0
- package/src/component/billing/checkout_button.ts +57 -0
- package/src/component/billing/payment_methods.html +27 -0
- package/src/component/billing/payment_methods.ts +36 -0
- package/src/component/billing/plan_selector.html +24 -0
- package/src/component/billing/plan_selector.ts +29 -0
- package/src/component/form/form_field.html +54 -0
- package/src/component/form/form_field.ts +156 -0
- package/src/component/form/form_record.html +14 -0
- package/src/component/form/form_record.ts +56 -0
- package/src/component/form/form_table.html +19 -0
- package/src/component/form/form_table.ts +58 -0
- package/src/component/login/account_deletion.html +42 -0
- package/src/component/login/account_deletion.ts +79 -0
- package/src/component/login/chpass_component.html +58 -0
- package/src/component/login/chpass_component.ts +96 -0
- package/src/component/login/confirm_chpass_component.html +47 -0
- package/src/component/login/confirm_chpass_component.ts +70 -0
- package/src/component/login/confirm_register_component.html +37 -0
- package/src/component/login/confirm_register_component.ts +71 -0
- package/src/component/login/consent_gate.html +30 -0
- package/src/component/login/consent_gate.ts +109 -0
- package/src/component/login/login_component.html +43 -0
- package/src/component/login/login_component.ts +73 -0
- package/src/component/login/otp_input.html +38 -0
- package/src/component/login/otp_input.ts +104 -0
- package/src/component/login/register_component.html +132 -0
- package/src/component/login/register_component.ts +184 -0
- package/src/component/login/social_login.html +13 -0
- package/src/component/login/social_login.ts +120 -0
- package/src/component/navigation/navigation.html +49 -0
- package/src/component/navigation/navigation.ts +69 -0
- package/src/component/security/trusted_devices.html +76 -0
- package/src/component/security/trusted_devices.ts +92 -0
- package/src/component/security/twofactor_setup.html +75 -0
- package/src/component/security/twofactor_setup.ts +106 -0
- package/src/component/security/twofactor_verify.html +45 -0
- package/src/component/security/twofactor_verify.ts +90 -0
- package/src/component/table/table_detail.html +53 -0
- package/src/component/table/table_detail.ts +133 -0
- package/src/component/table/table_edit.html +58 -0
- package/src/component/table/table_edit.ts +163 -0
- package/src/component/table/table_list.html +41 -0
- package/src/component/table/table_list.ts +100 -0
- package/src/component/table/table_lookup.html +35 -0
- package/src/component/table/table_lookup.ts +88 -0
- package/src/component/table/table_search.html +17 -0
- package/src/component/table/table_search.ts +80 -0
- package/src/config.ts +67 -0
- package/src/index.ts +41 -0
- package/src/model/appdata.ts +266 -0
- package/src/model/auth.ts +134 -0
- package/src/model/common.ts +115 -0
- package/src/model/decorator.ts +74 -0
- package/src/service/api-response.interceptor.ts +26 -0
- package/src/service/auth.interceptor.ts +11 -0
- package/src/service/auth.service.ts +576 -0
- package/src/service/billing.service.ts +65 -0
- package/src/service/label.service.ts +18 -0
- package/src/service/rest_service.ts +94 -0
- package/src/service/rest_url.ts +42 -0
- package/src/service/script_loader.ts +31 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<h1 class="mat-h1 table-edit-title">
|
|
2
|
+
<span class="table-edit-title-text">
|
|
3
|
+
@if (title) {
|
|
4
|
+
{{ title }}
|
|
5
|
+
} @else if (isNew) {
|
|
6
|
+
New {{ getCaption() }} Record
|
|
7
|
+
} @else {
|
|
8
|
+
{{ getCaption() }} Details
|
|
9
|
+
}
|
|
10
|
+
</span>
|
|
11
|
+
@if (isDialog) {
|
|
12
|
+
<button mat-icon-button type="button" class="table-edit-close"
|
|
13
|
+
(click)="onCancel()" aria-label="Close dialog">
|
|
14
|
+
<mat-icon>close</mat-icon>
|
|
15
|
+
</button>
|
|
16
|
+
}
|
|
17
|
+
</h1>
|
|
18
|
+
|
|
19
|
+
<div class="edit-container">
|
|
20
|
+
<div class="actions-bar">
|
|
21
|
+
@if (isReadOnlyMode) {
|
|
22
|
+
@if (canUpdate()) {
|
|
23
|
+
<button mat-raised-button class="primary" (click)="onEdit()">Edit</button>
|
|
24
|
+
}
|
|
25
|
+
@if (canCreate()) {
|
|
26
|
+
<button mat-raised-button class="accent" (click)="onNew()">New</button>
|
|
27
|
+
}
|
|
28
|
+
@if (canDelete()) {
|
|
29
|
+
<button mat-button class="warn" (click)="onDelete()">Delete</button>
|
|
30
|
+
}
|
|
31
|
+
} @else {
|
|
32
|
+
<button mat-raised-button class="primary" (click)="onSave()">Save</button>
|
|
33
|
+
<button mat-button (click)="onCancel()">Cancel</button>
|
|
34
|
+
}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<record-form [tableName]="tableName" [record]="editableRecord" [isReadOnly]="isReadOnlyBound"></record-form>
|
|
38
|
+
|
|
39
|
+
@if (tabCount > 0) {
|
|
40
|
+
<mat-tab-group>
|
|
41
|
+
@for (key of tabFields; track key) {
|
|
42
|
+
@if (cacheService.canRead(getTableName(key))) {
|
|
43
|
+
<mat-tab [label]="key">
|
|
44
|
+
<div class="tab-content">
|
|
45
|
+
<table-detail
|
|
46
|
+
[records]="editableRecord[key]"
|
|
47
|
+
[tableName]="getTableName(key)"
|
|
48
|
+
[parentTableName]="tableName"
|
|
49
|
+
[parentRecord]="editableRecord"
|
|
50
|
+
[parentReadOnly]="isReadOnlyMode">
|
|
51
|
+
</table-detail>
|
|
52
|
+
</div>
|
|
53
|
+
</mat-tab>
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</mat-tab-group>
|
|
57
|
+
}
|
|
58
|
+
</div>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
|
+
import { BaseForm } from "../abstract/base_form";
|
|
3
|
+
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
|
|
4
|
+
import { MatTabsModule } from "@angular/material/tabs";
|
|
5
|
+
import { TableDetail } from "./table_detail";
|
|
6
|
+
import { BackendService } from "../../service/rest_service";
|
|
7
|
+
import { RecordForm } from "../form/form_record";
|
|
8
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
9
|
+
import { MatIconModule } from "@angular/material/icon";
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'table-edit',
|
|
13
|
+
templateUrl: './table_edit.html',
|
|
14
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
|
+
encapsulation: ViewEncapsulation.None,
|
|
16
|
+
imports: [
|
|
17
|
+
RecordForm,
|
|
18
|
+
MatButtonModule,
|
|
19
|
+
MatIconModule,
|
|
20
|
+
MatTabsModule,
|
|
21
|
+
TableDetail,
|
|
22
|
+
]
|
|
23
|
+
})
|
|
24
|
+
export class TableEdit extends BaseForm implements OnInit {
|
|
25
|
+
@Input() record: any = {};
|
|
26
|
+
@Input() override tableName = '';
|
|
27
|
+
@Input() apiName = '';
|
|
28
|
+
isReadOnlyMode = true;
|
|
29
|
+
private readonly backendService = inject(BackendService);
|
|
30
|
+
private readonly dialogRef = inject(MatDialogRef<TableEdit>, {optional: true});
|
|
31
|
+
private readonly dialogData = inject<{record?: any; tableName?: string; apiName?: string; isNew?: boolean} | null>(MAT_DIALOG_DATA, {optional: true});
|
|
32
|
+
private readonly cdr = inject(ChangeDetectorRef);
|
|
33
|
+
|
|
34
|
+
protected tabCount = 0;
|
|
35
|
+
protected tabFields: string[] = [];
|
|
36
|
+
protected get isDialog(): boolean { return !!this.dialogRef; }
|
|
37
|
+
/** Snapshot taken when the user enters edit mode; used for dirty-tracking on save. */
|
|
38
|
+
private originalSnapshot: any = null;
|
|
39
|
+
|
|
40
|
+
ngOnInit(): void {
|
|
41
|
+
if (this.dialogData) {
|
|
42
|
+
this.record = this.dialogData.record ?? {};
|
|
43
|
+
this.tableName = this.dialogData.tableName ?? '';
|
|
44
|
+
this.apiName = this.dialogData.apiName ?? '';
|
|
45
|
+
this.isNew = this.dialogData.isNew ?? false;
|
|
46
|
+
this.isReadOnlyMode = !this.isNew;
|
|
47
|
+
}
|
|
48
|
+
if (this.isNew) {
|
|
49
|
+
this.initializeNewRecord();
|
|
50
|
+
this.buildTabs();
|
|
51
|
+
} else {
|
|
52
|
+
this.fetchFullRecord();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private initializeNewRecord() {
|
|
57
|
+
this.editableRecord = this.emptyRecord();
|
|
58
|
+
if (this.record) Object.assign(this.editableRecord, this.record);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private buildTabs() {
|
|
62
|
+
this.tabCount = 0;
|
|
63
|
+
this.tabFields = [];
|
|
64
|
+
for (const field of Object.keys(this.editableRecord)) {
|
|
65
|
+
if (this.isArray(this.editableRecord[field])) {
|
|
66
|
+
this.tabCount++;
|
|
67
|
+
this.tabFields.push(field);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private fetchFullRecord() {
|
|
73
|
+
this.editableRecord = {...this.record};
|
|
74
|
+
this.formatRecordTimeStamp(this.editableRecord);
|
|
75
|
+
|
|
76
|
+
const filter = this.getKeyFilters(this.record);
|
|
77
|
+
if (!filter) {
|
|
78
|
+
console.warn('Key definition could not be found');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.backendService.get<any>(this.apiName, filter).subscribe({
|
|
83
|
+
next: (record) => {
|
|
84
|
+
if (!record) {
|
|
85
|
+
console.error('failed to read single record from the backend');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.editableRecord = Array.isArray(record) ? record[0] : record;
|
|
89
|
+
this.formatRecordTimeStamp(this.editableRecord);
|
|
90
|
+
this.buildTabs();
|
|
91
|
+
this.cdr.markForCheck();
|
|
92
|
+
},
|
|
93
|
+
error: (error) => {
|
|
94
|
+
console.error('failed to fetch full record details', error);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override isReadOnly(fieldName: string): boolean {
|
|
100
|
+
if (this.isReadOnlyMode) return true;
|
|
101
|
+
return super.isReadOnly(fieldName);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
onEdit() {
|
|
105
|
+
this.isReadOnlyMode = false;
|
|
106
|
+
this.isNew = false;
|
|
107
|
+
this.isReadOnlyBound = this.isReadOnly.bind(this);
|
|
108
|
+
// Snapshot scalar fields only; children arrays are dirty-tracked per row.
|
|
109
|
+
this.originalSnapshot = { ...this.editableRecord };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onNew() {
|
|
113
|
+
this.isReadOnlyMode = false;
|
|
114
|
+
this.isNew = true;
|
|
115
|
+
this.editableRecord = this.emptyRecord();
|
|
116
|
+
this.isReadOnlyBound = this.isReadOnly.bind(this);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onSave() {
|
|
120
|
+
const result = {...this.editableRecord};
|
|
121
|
+
this.readyToSave(result, this.originalSnapshot);
|
|
122
|
+
|
|
123
|
+
if (this.dialogRef) {
|
|
124
|
+
this.dialogRef.close(result);
|
|
125
|
+
} else {
|
|
126
|
+
this.backendService.post(this.apiName, result).subscribe({
|
|
127
|
+
next: () => {
|
|
128
|
+
this.isReadOnlyMode = true;
|
|
129
|
+
this.isNew = false;
|
|
130
|
+
this.isReadOnlyBound = this.isReadOnly.bind(this);
|
|
131
|
+
this.cdr.markForCheck();
|
|
132
|
+
},
|
|
133
|
+
error: (err) => console.error('Save failed', err),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
onDelete() {
|
|
139
|
+
if (confirm('Are you sure you want to delete this record?')) {
|
|
140
|
+
const result = {...this.editableRecord};
|
|
141
|
+
result[this.config.opField] = 'D';
|
|
142
|
+
this.backendService.post(this.apiName, result).subscribe({
|
|
143
|
+
next: () => {
|
|
144
|
+
this.isReadOnlyMode = true;
|
|
145
|
+
this.isReadOnlyBound = this.isReadOnly.bind(this);
|
|
146
|
+
this.cdr.markForCheck();
|
|
147
|
+
},
|
|
148
|
+
error: (err) => console.error('Delete failed', err),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onCancel() {
|
|
154
|
+
if (this.dialogRef) {
|
|
155
|
+
this.dialogRef.close();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.isReadOnlyMode = true;
|
|
159
|
+
this.editableRecord = {...this.record};
|
|
160
|
+
this.isNew = false;
|
|
161
|
+
this.isReadOnlyBound = this.isReadOnly.bind(this);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<h2>{{ getCaption() }}</h2>
|
|
2
|
+
|
|
3
|
+
@if (canCreate()) {
|
|
4
|
+
<button mat-raised-button class="accent" (click)="addRecord()">New Record</button>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
@for (column of displayedColumns; track column) {
|
|
11
|
+
<th>{{ colCaption(column) }}</th>
|
|
12
|
+
}
|
|
13
|
+
</tr>
|
|
14
|
+
</thead>
|
|
15
|
+
@if (records.length > 0) {
|
|
16
|
+
<tbody>
|
|
17
|
+
@for (record of records; track trackByRecord($index, record)) {
|
|
18
|
+
<tr>
|
|
19
|
+
@for (column of displayedColumns; track column) {
|
|
20
|
+
<td>
|
|
21
|
+
@if (isLinkColumn(column)) {
|
|
22
|
+
<a tabindex="0" role="button" (click)="editRecord(record)" (keydown.enter)="editRecord(record)">{{ displayValue(column, record[column]) }}</a>
|
|
23
|
+
} @else if (isBoolean(column)) {
|
|
24
|
+
<mat-checkbox [checked]="record[column] === true || record[column] === 'T'" disabled></mat-checkbox>
|
|
25
|
+
} @else if (column !== 'actions') {
|
|
26
|
+
{{ displayValue(column, record[column]) }}
|
|
27
|
+
} @else {
|
|
28
|
+
<button mat-icon-button type="button" (click)="editRecord(record)" title="Edit"><mat-icon>edit</mat-icon></button>
|
|
29
|
+
<button mat-icon-button type="button" (click)="deleteRecord(record)" title="Delete"><mat-icon>delete</mat-icon></button>
|
|
30
|
+
}
|
|
31
|
+
</td>
|
|
32
|
+
}
|
|
33
|
+
</tr>
|
|
34
|
+
}
|
|
35
|
+
</tbody>
|
|
36
|
+
}
|
|
37
|
+
</table>
|
|
38
|
+
|
|
39
|
+
@if (records.length === 0 && displayedColumns.length > 0) {
|
|
40
|
+
<p class="empty-state">No records found.</p>
|
|
41
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
|
+
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
|
3
|
+
import { ActivatedRoute, Params } from "@angular/router";
|
|
4
|
+
import { BaseView } from "../abstract/base_view";
|
|
5
|
+
import { TableEdit } from "./table_edit";
|
|
6
|
+
import { BackendService } from "../../service/rest_service";
|
|
7
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
8
|
+
import { MatCheckboxModule } from "@angular/material/checkbox";
|
|
9
|
+
import { MatIconModule } from "@angular/material/icon";
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'table-list',
|
|
13
|
+
templateUrl: './table_list.html',
|
|
14
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
|
+
encapsulation: ViewEncapsulation.None,
|
|
16
|
+
imports: [MatButtonModule, MatCheckboxModule, MatIconModule],
|
|
17
|
+
})
|
|
18
|
+
export class TableList extends BaseView implements OnInit {
|
|
19
|
+
override dialogWidth = '400px';
|
|
20
|
+
|
|
21
|
+
private readonly route = inject(ActivatedRoute);
|
|
22
|
+
private readonly backendService = inject(BackendService);
|
|
23
|
+
private readonly cdr = inject(ChangeDetectorRef);
|
|
24
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
25
|
+
|
|
26
|
+
ngOnInit() {
|
|
27
|
+
const data = this.route.snapshot.data;
|
|
28
|
+
if (!this.tableName && data['tableName']) this.tableName = data['tableName'];
|
|
29
|
+
if (!this.apiName && data['apiName']) this.apiName = data['apiName'];
|
|
30
|
+
if (!this.dialogComponent) this.dialogComponent = data['dialogComponent'] ?? TableEdit;
|
|
31
|
+
this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => this.handleQueryParams(params));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
protected handleDelete(record: {[key: string]: any}): void {
|
|
35
|
+
if (!this.canDelete()) {
|
|
36
|
+
alert('Missing authorization to delete records');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (confirm('Are you sure you want to delete this record?')) {
|
|
40
|
+
const keyFilter = this.getKeyFilters(record);
|
|
41
|
+
this.backendService.delete(this.apiName, keyFilter).subscribe({
|
|
42
|
+
next: () => this.fetchRecords(),
|
|
43
|
+
error: (err) => console.error('Delete failed', err),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
protected handleUpdate(result: {[key: string]: any}, isNew: boolean, _original: {[key: string]: any}): void {
|
|
49
|
+
if (isNew && !this.canCreate()) {
|
|
50
|
+
alert('Missing authorization to create records');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!isNew && !this.canUpdate()) {
|
|
54
|
+
alert('Missing authorization to update records');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (confirm('Are you sure you want to save this record?')) {
|
|
58
|
+
this.backendService.post(this.apiName, result).subscribe({
|
|
59
|
+
next: () => this.fetchRecords(),
|
|
60
|
+
error: (err) => console.error('Save failed', err),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fetchRecords() {
|
|
66
|
+
if (!this.canRead()) {
|
|
67
|
+
alert('Missing authorization to read records');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.backendService.list<{[key: string]: any}>(this.apiName, this.searchTerms).subscribe({
|
|
71
|
+
next: (records) => {
|
|
72
|
+
this.records = records;
|
|
73
|
+
this.updateDisplayedColumns(records);
|
|
74
|
+
this.cdr.markForCheck();
|
|
75
|
+
},
|
|
76
|
+
error: (err) => console.error('Fetch failed', err),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
handleQueryParams(params: Params) {
|
|
81
|
+
const newSearchTerms: {[key: string]: string} = {};
|
|
82
|
+
let hasSearchTerms = false;
|
|
83
|
+
let action = '';
|
|
84
|
+
for (const key in params) {
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(params, key)) {
|
|
86
|
+
if (key === '_action') {
|
|
87
|
+
action = params[key];
|
|
88
|
+
} else {
|
|
89
|
+
newSearchTerms[key] = params[key];
|
|
90
|
+
hasSearchTerms = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (hasSearchTerms) this.searchTerms = newSearchTerms;
|
|
95
|
+
this.fetchRecords();
|
|
96
|
+
if (action === 'create') {
|
|
97
|
+
setTimeout(() => this.addRecord(), 500);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<h2 mat-dialog-title>{{ getCaption() }} Search</h2>
|
|
2
|
+
<div mat-dialog-content>
|
|
3
|
+
<record-form [tableName]="tableName" [record]="searchRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></record-form>
|
|
4
|
+
<div class="search-actions">
|
|
5
|
+
<button mat-raised-button class="primary" (click)="onSearch()">Search</button>
|
|
6
|
+
<button mat-button (click)="onClear()">Clear</button>
|
|
7
|
+
</div>
|
|
8
|
+
@if (records.length > 0) {
|
|
9
|
+
<table>
|
|
10
|
+
<thead>
|
|
11
|
+
<tr>
|
|
12
|
+
<th>Select</th>
|
|
13
|
+
@for (column of displayedColumns; track column) {
|
|
14
|
+
<th>{{ colCaption(column) }}</th>
|
|
15
|
+
}
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
@for (record of records; track trackByRecord($index, record)) {
|
|
20
|
+
<tr>
|
|
21
|
+
<td><button class="select-btn" (click)="onSelect(record)">Select</button></td>
|
|
22
|
+
@for (column of displayedColumns; track column) {
|
|
23
|
+
<td>{{ displayValue(column, record[column]) }}</td>
|
|
24
|
+
}
|
|
25
|
+
</tr>
|
|
26
|
+
}
|
|
27
|
+
</tbody>
|
|
28
|
+
</table>
|
|
29
|
+
} @else {
|
|
30
|
+
<p>No records found</p>
|
|
31
|
+
}
|
|
32
|
+
</div>
|
|
33
|
+
<div mat-dialog-actions>
|
|
34
|
+
<button mat-button [mat-dialog-close]>Cancel</button>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
3
|
+
import { MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from "@angular/material/dialog";
|
|
4
|
+
import { MatIconModule } from "@angular/material/icon";
|
|
5
|
+
import { RecordForm } from "../form/form_record";
|
|
6
|
+
import { BaseView } from "../abstract/base_view";
|
|
7
|
+
import { BackendService } from "../../service/rest_service";
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'table-lookup',
|
|
11
|
+
templateUrl: './table_lookup.html',
|
|
12
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
|
+
encapsulation: ViewEncapsulation.None,
|
|
14
|
+
imports: [
|
|
15
|
+
MatButtonModule,
|
|
16
|
+
MatDialogActions,
|
|
17
|
+
MatDialogClose,
|
|
18
|
+
MatDialogContent,
|
|
19
|
+
MatDialogTitle,
|
|
20
|
+
MatIconModule,
|
|
21
|
+
RecordForm,
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
export class TableLookup extends BaseView implements OnInit {
|
|
25
|
+
override apiName = '';
|
|
26
|
+
searchRecord: any = {};
|
|
27
|
+
searchColumns: string[] = [];
|
|
28
|
+
|
|
29
|
+
private readonly backendService = inject(BackendService);
|
|
30
|
+
private readonly dialogRef = inject(MatDialogRef<TableLookup>);
|
|
31
|
+
private readonly data = inject<{tableName?: string; apiName?: string}>(MAT_DIALOG_DATA);
|
|
32
|
+
protected readonly cdr = inject(ChangeDetectorRef);
|
|
33
|
+
|
|
34
|
+
ngOnInit() {
|
|
35
|
+
if (this.data) {
|
|
36
|
+
this.tableName = this.data.tableName ?? '';
|
|
37
|
+
this.apiName = this.data.apiName ?? '';
|
|
38
|
+
}
|
|
39
|
+
this.searchColumns = this.getDisplayedColumns();
|
|
40
|
+
this.searchRecord = this.emptyRecord();
|
|
41
|
+
this.resolveApi();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private resolveApi() {
|
|
45
|
+
if (!this.apiName && this.tableName) {
|
|
46
|
+
this.cacheService.getAppData().subscribe((data) => {
|
|
47
|
+
if (data.Apis) {
|
|
48
|
+
for (const api in data.Apis) {
|
|
49
|
+
const entry = data.Apis[api];
|
|
50
|
+
if (entry.Table.TableName === this.tableName) {
|
|
51
|
+
this.apiName = entry.Version ? entry.Version + '/' + api : api;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
onSearch() {
|
|
61
|
+
this.resolveApi();
|
|
62
|
+
if (!this.apiName) {
|
|
63
|
+
console.error('API name not found for table ' + this.tableName);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.backendService.list<{[key: string]: any}>(this.apiName, this.buildSearchTerms(this.searchRecord)).subscribe({
|
|
67
|
+
next: (records) => {
|
|
68
|
+
this.records = records;
|
|
69
|
+
this.updateDisplayedColumns(records);
|
|
70
|
+
this.cdr.markForCheck();
|
|
71
|
+
},
|
|
72
|
+
error: (err) => console.error('Lookup search failed', err),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onSelect(record: any) {
|
|
77
|
+
this.dialogRef.close(record);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onClear() {
|
|
81
|
+
this.searchRecord = this.emptyRecord();
|
|
82
|
+
this.records = [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
protected handleDelete(_record: {[key: string]: any}): void {}
|
|
86
|
+
|
|
87
|
+
protected handleUpdate(_result: {[key: string]: any}, _isNew: boolean, _original: {[key: string]: any}): void {}
|
|
88
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<h1>
|
|
2
|
+
@if (title) {
|
|
3
|
+
{{ title }}
|
|
4
|
+
} @else {
|
|
5
|
+
{{ getCaption() }} Search
|
|
6
|
+
}
|
|
7
|
+
</h1>
|
|
8
|
+
<div class="page-container">
|
|
9
|
+
<record-form [tableName]="tableName" [record]="editableRecord" [fields]="searchColumns" [isReadOnly]="isReadOnlyBound"></record-form>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="search-actions">
|
|
12
|
+
<button mat-raised-button class="primary" (click)="onSearch()">Search</button>
|
|
13
|
+
<button mat-button (click)="onClear()">Clear</button>
|
|
14
|
+
@if (canCreate()) {
|
|
15
|
+
<button mat-raised-button class="accent" (click)="onAddRecord()">New Record</button>
|
|
16
|
+
}
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit, ViewEncapsulation } from "@angular/core";
|
|
2
|
+
import { MatButtonModule } from "@angular/material/button";
|
|
3
|
+
import { ActivatedRoute, Router } from "@angular/router";
|
|
4
|
+
import { BaseForm } from "../abstract/base_form";
|
|
5
|
+
import { RecordForm } from "../form/form_record";
|
|
6
|
+
import { ApplicationMenu } from "../../model/common";
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'table-search',
|
|
10
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
11
|
+
encapsulation: ViewEncapsulation.None,
|
|
12
|
+
templateUrl: './table_search.html',
|
|
13
|
+
imports: [
|
|
14
|
+
MatButtonModule,
|
|
15
|
+
RecordForm,
|
|
16
|
+
],
|
|
17
|
+
})
|
|
18
|
+
export class TableSearch extends BaseForm implements OnInit {
|
|
19
|
+
@Input() override tableName = '';
|
|
20
|
+
@Input() apiName: string = '';
|
|
21
|
+
@Input() targetRoute: string = '';
|
|
22
|
+
searchColumns: string[] = [];
|
|
23
|
+
|
|
24
|
+
private readonly router = inject(Router);
|
|
25
|
+
private readonly route = inject(ActivatedRoute);
|
|
26
|
+
private readonly cdr = inject(ChangeDetectorRef);
|
|
27
|
+
|
|
28
|
+
ngOnInit() {
|
|
29
|
+
const data = this.route.snapshot.data;
|
|
30
|
+
if (!this.tableName && data['tableName']) this.tableName = data['tableName'];
|
|
31
|
+
if (!this.apiName && data['apiName']) this.apiName = data['apiName'];
|
|
32
|
+
if (!this.targetRoute && data['targetRoute']) this.targetRoute = data['targetRoute'];
|
|
33
|
+
this.title = this.getCaption() + ' Search';
|
|
34
|
+
if (!this.targetRoute && (this.apiName || this.tableName)) {
|
|
35
|
+
this.cacheService.getMenus().subscribe((menus: ApplicationMenu[]) => {
|
|
36
|
+
for (const menu of menus) {
|
|
37
|
+
for (const page of menu.ApplicationMenuItems!) {
|
|
38
|
+
if (this.apiName.endsWith(page.RestUri!) ||
|
|
39
|
+
(this.tableName && page.RestUri!.includes(this.tableName))
|
|
40
|
+
) {
|
|
41
|
+
if (page.FilterOnList) {
|
|
42
|
+
this.targetRoute = '/' + menu.Id!.toLowerCase() + '/' + page.ItemId!;
|
|
43
|
+
} else {
|
|
44
|
+
this.targetRoute = '/' + menu.Id!.toLowerCase() + '/' + page.ItemId! + '/list';
|
|
45
|
+
}
|
|
46
|
+
this.cdr.markForCheck();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
this.editableRecord = this.emptyRecord();
|
|
54
|
+
this.searchColumns = this.getDisplayedColumns();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onSearch() {
|
|
58
|
+
if (!this.targetRoute) {
|
|
59
|
+
alert('Navigation target not found for this table');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.router.navigate([this.targetRoute], {queryParams: this.buildSearchTerms(this.editableRecord)});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onClear() {
|
|
66
|
+
this.editableRecord = this.emptyRecord();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
onAddRecord() {
|
|
70
|
+
if (!this.canCreate()) {
|
|
71
|
+
alert('Missing authorization to create records');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!this.targetRoute) {
|
|
75
|
+
alert('Navigation target not found for this table');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.router.navigate([this.targetRoute], {queryParams: {'_action': 'create'}});
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { InjectionToken, Type } from '@angular/core';
|
|
2
|
+
import { Routes } from '@angular/router';
|
|
3
|
+
import { ApplicationData } from './model/appdata';
|
|
4
|
+
|
|
5
|
+
export interface RouteLink {
|
|
6
|
+
label: string;
|
|
7
|
+
routerLink: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TableOverride {
|
|
11
|
+
/** Fields to hide from edit forms in addition to global hiddenFields. Use PascalName. */
|
|
12
|
+
hiddenFields?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Default values applied to new records. Use PascalName.
|
|
15
|
+
* Values may be a literal or a zero-arg function (evaluated at record creation).
|
|
16
|
+
* Use a function for time-sensitive defaults like `() => new Date().toISOString()`.
|
|
17
|
+
*/
|
|
18
|
+
defaultValues?: { [field: string]: unknown | (() => unknown) };
|
|
19
|
+
/**
|
|
20
|
+
* Timestamp columns that look auto-managed (HasDefault + DataType=timestamp)
|
|
21
|
+
* but are actually user-meaningful (e.g. begda, start_date, end_date).
|
|
22
|
+
* Listing them here keeps them visible and editable in forms;
|
|
23
|
+
* they're otherwise filtered out as audit columns.
|
|
24
|
+
*/
|
|
25
|
+
editableTimestamps?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SailGuiConfig {
|
|
29
|
+
opField: string;
|
|
30
|
+
hiddenFields: string[];
|
|
31
|
+
restUrls?: { [key: string]: string };
|
|
32
|
+
publicRoutes?: Routes;
|
|
33
|
+
extraRoutes?: (data: ApplicationData) => Routes;
|
|
34
|
+
dashboardComponent?: Type<any>;
|
|
35
|
+
appTitle?: string;
|
|
36
|
+
googleMapsApiKey?: string;
|
|
37
|
+
loginFooterLinks?: RouteLink[];
|
|
38
|
+
/** Links shown in navigation toolbar when not logged in (e.g., Login, Register) */
|
|
39
|
+
publicRouteLinks?: RouteLink[];
|
|
40
|
+
/** Map RestUri (or prefix pattern like 'analytic/*') to a custom component */
|
|
41
|
+
menuItemRouteOverrides?: { [restUriPattern: string]: Type<unknown> };
|
|
42
|
+
/** Per-table overrides for hiding fields / seeding defaults. Keyed by table PascalName. */
|
|
43
|
+
tableOverrides?: { [tableName: string]: TableOverride };
|
|
44
|
+
|
|
45
|
+
// ── Social / consent / account-deletion config ──
|
|
46
|
+
/** Google Identity Services client ID. Required to render the Google button in SocialLoginComponent. */
|
|
47
|
+
googleClientId?: string;
|
|
48
|
+
/** Apple Services ID (Apple Developer → Identifier → Services ID). Required for the Apple button. */
|
|
49
|
+
appleServiceId?: string;
|
|
50
|
+
/** Apple Sign-In redirect URI. Must match the Services ID configuration. */
|
|
51
|
+
appleRedirectUri?: string;
|
|
52
|
+
/** Public URL of your privacy policy. Shown as a link in ConsentGateComponent. */
|
|
53
|
+
privacyPolicyUrl?: string;
|
|
54
|
+
/** Content hash / version of the currently-deployed privacy policy. */
|
|
55
|
+
defaultPolicyVersion?: string;
|
|
56
|
+
/** ISO 639-1 language code of the policy variant shown ('en', 'fr', ...). */
|
|
57
|
+
defaultPolicyLanguage?: string;
|
|
58
|
+
/** Route to navigate to after account deletion. Defaults to '/login/local'. */
|
|
59
|
+
accountDeletedRoute?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const SAIL_GUI_CONFIG = new InjectionToken<SailGuiConfig>('SailGuiConfig');
|
|
63
|
+
|
|
64
|
+
export const DEFAULT_CONFIG: SailGuiConfig = {
|
|
65
|
+
opField: 'op_code',
|
|
66
|
+
hiddenFields: ['op_code', 'PartnerId'],
|
|
67
|
+
};
|