@nauticana/sail 0.8.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +141 -16
  2. package/package.json +2 -2
  3. package/src/component/abstract/base_async.ts +8 -2
  4. package/src/component/abstract/base_table.ts +94 -35
  5. package/src/component/abstract/base_view.ts +28 -41
  6. package/src/component/billing/checkout_button.ts +1 -1
  7. package/src/component/billing/payment_methods.ts +1 -1
  8. package/src/component/billing/plan_selector.ts +1 -1
  9. package/src/component/form/form_field.ts +7 -10
  10. package/src/component/form/form_record.html +2 -2
  11. package/src/component/form/form_record.ts +1 -1
  12. package/src/component/form/form_table.html +1 -1
  13. package/src/component/form/form_table.ts +1 -1
  14. package/src/component/login/account_deletion.ts +1 -1
  15. package/src/component/login/chpass_component.ts +11 -16
  16. package/src/component/login/confirm_chpass_component.ts +9 -15
  17. package/src/component/login/confirm_register_component.ts +9 -15
  18. package/src/component/login/consent_gate.ts +1 -1
  19. package/src/component/login/login_component.html +1 -1
  20. package/src/component/login/login_component.ts +1 -1
  21. package/src/component/login/otp_input.ts +1 -1
  22. package/src/component/login/register_component.ts +1 -1
  23. package/src/component/login/social_login.ts +1 -1
  24. package/src/component/navigation/navigation.ts +10 -11
  25. package/src/component/security/trusted_devices.ts +1 -1
  26. package/src/component/security/twofactor_setup.ts +18 -26
  27. package/src/component/security/twofactor_verify.ts +10 -12
  28. package/src/component/table/table_detail.html +4 -4
  29. package/src/component/table/table_detail.ts +21 -33
  30. package/src/component/table/table_edit.html +13 -3
  31. package/src/component/table/table_edit.ts +17 -17
  32. package/src/component/table/table_list.ts +18 -52
  33. package/src/component/table/table_lookup.html +1 -1
  34. package/src/component/table/table_lookup.ts +1 -3
  35. package/src/component/table/table_report.ts +11 -14
  36. package/src/component/table/table_search.html +1 -1
  37. package/src/component/table/table_search.ts +13 -37
  38. package/src/index.ts +2 -0
  39. package/src/model/appdata.ts +2 -2
  40. package/src/model/common.ts +39 -1
  41. package/src/service/auth.service.ts +26 -26
  42. package/src/service/base_rest.service.ts +20 -0
  43. package/src/service/billing.service.ts +3 -9
  44. package/src/service/payout.service.ts +3 -9
  45. package/src/service/rest_service.ts +5 -5
  46. package/src/service/user_payment_method.service.ts +4 -8
  47. 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',
@@ -21,12 +21,14 @@ import { TableAction } from "../../model/appdata";
21
21
  })
22
22
  export class TableDetail extends BaseForm implements OnInit {
23
23
  readonly tableNameInput = input('', { alias: 'tableName' });
24
- readonly recordsInput = input<any[]>([], { alias: 'records' });
24
+ 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
- effect(() => { const v = this.tableNameInput(); if (v) this.tableName.set(v); });
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
- * Fire a TableAction. Per-row actions require the record to be
47
- * server-side (not 'I' inserted-locally-not-saved); table-level
48
- * actions only need the parent to be writable. Action POSTs hit
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
- executeAction(action: TableAction, record?: {[key: string]: unknown}): void {
53
- if (!this.canExecuteAction(action)) {
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
- if (action.confirmMessage && !confirm(action.confirmMessage)) return;
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] = 'I';
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] === 'I';
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] === 'I') {
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] = 'D';
149
+ record[this.config.opField] = OpCode.Delete;
162
150
  }
163
151
  }
164
152
 
165
153
  unDeleteRow(record: any) {
166
- if (record[this.config.opField] === 'D') {
167
- record[this.config.opField] = 'U';
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, effect, inject, input, OnInit, signal, ViewEncapsulation } from "@angular/core";
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 = input<any>({}, { alias: 'record' });
26
- readonly tableNameInput = input('', { alias: 'tableName' });
27
- readonly apiNameInput = input('', { alias: 'apiName' });
28
- readonly record = signal<any>({});
29
- readonly apiName = signal('');
25
+ readonly recordInput = input<any>({}, { alias: 'record' });
26
+ readonly tableNameInput = input('', { alias: 'tableName' });
27
+ 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] = 'D';
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, effect, inject, input, OnInit, ViewEncapsulation } from "@angular/core";
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 = input('', { alias: 'tableName' });
22
- readonly apiNameInput = input('', { alias: 'apiName' });
23
- readonly dialogComponentInput = input<any>(undefined, { alias: 'dialogComponent' });
19
+ readonly tableNameInput = input('', { alias: 'tableName' });
20
+ readonly apiNameInput = input('', { alias: 'apiName' });
21
+ 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 && !this.canCreate()) {
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
- * Fire a TableAction. For record-specific actions, `record` carries
93
- * the row's primary key columns (lifted via BaseTable.primaryKeyValues).
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 { HttpClient, HttpParams } from "@angular/common/http";
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 { RestURL } from "../../service/rest_url";
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 || this.titleCase(this.tableName());
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
- const url = RestURL.httpHost + '/api/' + api;
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
- titleCase(str: string): string {
154
- return str.replaceAll('_', ' ').toLowerCase().split(' ')
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, effect, inject, input, OnInit, signal, ViewEncapsulation } from "@angular/core";
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 = input('', { alias: 'tableName' });
24
- readonly apiNameInput = input('', { alias: 'apiName' });
21
+ readonly tableNameInput = input('', { alias: 'tableName' });
22
+ readonly apiNameInput = input('', { alias: 'apiName' });
25
23
  readonly targetRouteInput = input('', { alias: 'targetRoute' });
26
- readonly apiName = signal('');
27
- readonly targetRoute = signal('');
28
- searchColumns: string[] = [];
29
24
 
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.targetRouteInput();if (v) this.targetRoute.set(v); });
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
- * Fire a table-level TableAction from the search-screen toolbar.
44
- * Record-specific actions never show up here — search has no
45
- * row context.
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
@@ -45,3 +45,5 @@ export * from './service/api-response.interceptor';
45
45
  export * from './service/rest_service';
46
46
  export * from './service/rest_url';
47
47
  export * from './service/label.service';
48
+ export * from './service/base_rest.service';
49
+ export * from './util/text';
@@ -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 `<app-checkout-button>` flows.
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
- // `<app-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
209
+ // `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
210
210
  priceId?: string;
211
211
  }
212
212
 
@@ -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 = 'S';
42
+ op_code: OpCodeValue = OpCode.Select;
5
43
  }
6
44
 
7
45
  export class UserAccountPolicy extends SiudAction {