@nauticana/sail 1.0.7 → 1.0.9
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/package.json +1 -1
- package/src/component/abstract/base_table.ts +33 -12
- package/src/component/form/form_field.ts +1 -0
- package/src/component/table/table_detail.html +1 -1
- package/src/component/table/table_detail.ts +9 -1
- package/src/model/appdata.ts +1 -1
- package/src/service/auth.service.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -267,12 +267,26 @@ export abstract class BaseTable {
|
|
|
267
267
|
return String(rawValue);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Temporal input kind for a field, or null if not temporal. Drives both the
|
|
272
|
+
* load-format and save-restore so TIME/DATE columns aren't given the
|
|
273
|
+
* datetime-local (seconds + 'Z') treatment a Postgres TIME/DATE rejects.
|
|
274
|
+
*/
|
|
275
|
+
temporalKind(fieldName: string): 'time' | 'date' | 'datetime-local' | null {
|
|
271
276
|
const col = this.getColumn(fieldName)
|
|
272
|
-
if (col)
|
|
273
|
-
|
|
277
|
+
if (col?.InputType === 'time') return 'time'
|
|
278
|
+
if (col?.InputType === 'date') return 'date'
|
|
279
|
+
if (col?.InputType === 'datetime-local') return 'datetime-local'
|
|
280
|
+
// Fallback for column metadata predating keel's time/date split.
|
|
281
|
+
if (col ? col.DataType === 'timestamp'
|
|
282
|
+
: (fieldName.endsWith('Time') || fieldName.endsWith('time'))) {
|
|
283
|
+
return 'datetime-local'
|
|
274
284
|
}
|
|
275
|
-
return
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
isTimeStamp(fieldName: string): boolean {
|
|
289
|
+
return this.temporalKind(fieldName) !== null
|
|
276
290
|
}
|
|
277
291
|
|
|
278
292
|
isNumber(fieldName: string): boolean {
|
|
@@ -303,9 +317,11 @@ export abstract class BaseTable {
|
|
|
303
317
|
return Array.isArray(value);
|
|
304
318
|
}
|
|
305
319
|
|
|
306
|
-
formatTimeStamp(val: string): string {
|
|
320
|
+
formatTimeStamp(val: string, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): string {
|
|
307
321
|
if (val && typeof val === 'string') {
|
|
308
|
-
return val.slice(0,
|
|
322
|
+
if (kind === 'time') return val.slice(0, 5); // HH:MM
|
|
323
|
+
if (kind === 'date') return val.slice(0, 10); // YYYY-MM-DD
|
|
324
|
+
return val.slice(0, 16); // YYYY-MM-DDTHH:MM
|
|
309
325
|
}
|
|
310
326
|
return val;
|
|
311
327
|
}
|
|
@@ -313,16 +329,20 @@ export abstract class BaseTable {
|
|
|
313
329
|
formatRecordTimeStamp(record: any) {
|
|
314
330
|
if (record) {
|
|
315
331
|
for (const key of Object.keys(record)) {
|
|
316
|
-
|
|
317
|
-
|
|
332
|
+
const kind = this.temporalKind(key);
|
|
333
|
+
if (kind) {
|
|
334
|
+
record[key] = this.formatTimeStamp(record[key], kind);
|
|
318
335
|
}
|
|
319
336
|
}
|
|
320
337
|
}
|
|
321
338
|
}
|
|
322
339
|
|
|
323
|
-
restoreTimeStamp(val: any): any {
|
|
324
|
-
// PostgreSQL can't cast '' to
|
|
340
|
+
restoreTimeStamp(val: any, kind: 'time' | 'date' | 'datetime-local' = 'datetime-local'): any {
|
|
341
|
+
// PostgreSQL can't cast '' to a temporal type — normalize empty to null.
|
|
325
342
|
if (val === '' || val === undefined || val === null) return null;
|
|
343
|
+
// TIME/DATE values are sent verbatim ('08:45', '2026-06-15'); only
|
|
344
|
+
// datetime-local needs the seconds + UTC marker the picker omits.
|
|
345
|
+
if (kind === 'time' || kind === 'date') return val;
|
|
326
346
|
if (typeof val === 'string' && !val.endsWith('Z')) {
|
|
327
347
|
return val + ':00Z';
|
|
328
348
|
}
|
|
@@ -332,8 +352,9 @@ export abstract class BaseTable {
|
|
|
332
352
|
restoreRecordTimeStamp(record: any) {
|
|
333
353
|
if (record) {
|
|
334
354
|
for (const key of Object.keys(record)) {
|
|
335
|
-
|
|
336
|
-
|
|
355
|
+
const kind = this.temporalKind(key);
|
|
356
|
+
if (kind) {
|
|
357
|
+
record[key] = this.restoreTimeStamp(record[key], kind);
|
|
337
358
|
}
|
|
338
359
|
}
|
|
339
360
|
}
|
|
@@ -72,6 +72,7 @@ export class DynamicField extends BaseTable {
|
|
|
72
72
|
get isDateLike(): boolean {
|
|
73
73
|
if (!this.col) return false;
|
|
74
74
|
return this.col.InputType === 'date'
|
|
75
|
+
|| this.col.InputType === 'time'
|
|
75
76
|
|| this.col.InputType === 'datetime'
|
|
76
77
|
|| this.col.InputType === 'datetime-local';
|
|
77
78
|
}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
</td>
|
|
39
39
|
}
|
|
40
40
|
<td>
|
|
41
|
-
<button matIconButton (click)="saveRow()" title="
|
|
41
|
+
<button matIconButton (click)="saveRow()" [title]="isNew ? 'Add' : 'Update'"><mat-icon>{{ isNew ? 'add' : 'check' }}</mat-icon></button>
|
|
42
42
|
<button matIconButton (click)="cancelRow(record)" title="Cancel"><mat-icon>cancel</mat-icon></button>
|
|
43
43
|
</td>
|
|
44
44
|
} @else {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal, OnInit, ViewEncapsulation } from "@angular/core";
|
|
1
|
+
import { ChangeDetectionStrategy, Component, effect, inject, input, linkedSignal, OnInit, output, 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";
|
|
@@ -26,6 +26,9 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
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
|
+
/** Fires after any grid mutation (add/edit/delete/undelete/cancel) so a parent
|
|
30
|
+
* can re-evaluate unsaved-changes state. Local only — no server call. */
|
|
31
|
+
readonly changed = output<void>();
|
|
29
32
|
|
|
30
33
|
override readonly tableName = linkedSignal<string, string>({ source: () => this.tableNameInput(), computation: (v, p) => v || p?.value || '' });
|
|
31
34
|
|
|
@@ -88,6 +91,7 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
88
91
|
} else {
|
|
89
92
|
Object.assign(record, result);
|
|
90
93
|
}
|
|
94
|
+
this.changed.emit();
|
|
91
95
|
}
|
|
92
96
|
});
|
|
93
97
|
}
|
|
@@ -123,6 +127,7 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
123
127
|
this.editingRecord = null;
|
|
124
128
|
this.originalRecord = null;
|
|
125
129
|
this.isNew = false;
|
|
130
|
+
this.changed.emit();
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
cancelRow(record: any) {
|
|
@@ -137,6 +142,7 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
137
142
|
this.editingRecord = null;
|
|
138
143
|
this.originalRecord = null;
|
|
139
144
|
this.isNew = false;
|
|
145
|
+
this.changed.emit();
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
deleteRow(record: any) {
|
|
@@ -148,11 +154,13 @@ export class TableDetail extends BaseForm implements OnInit {
|
|
|
148
154
|
} else {
|
|
149
155
|
record[this.config.opField] = OpCode.Delete;
|
|
150
156
|
}
|
|
157
|
+
this.changed.emit();
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
unDeleteRow(record: any) {
|
|
154
161
|
if (record[this.config.opField] === OpCode.Delete) {
|
|
155
162
|
record[this.config.opField] = OpCode.Update;
|
|
156
163
|
}
|
|
164
|
+
this.changed.emit();
|
|
157
165
|
}
|
|
158
166
|
}
|
package/src/model/appdata.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface TableColumn {
|
|
|
40
40
|
PascalName: string;
|
|
41
41
|
Caption: string;
|
|
42
42
|
DataType: string;
|
|
43
|
-
InputType: 'text' | 'password' | 'number' | 'date' | 'datetime' | 'datetime-local' | 'textarea' | 'select' | 'checkbox';
|
|
43
|
+
InputType: 'text' | 'password' | 'number' | 'date' | 'time' | 'datetime' | 'datetime-local' | 'textarea' | 'select' | 'checkbox';
|
|
44
44
|
Size: number;
|
|
45
45
|
Scale: number;
|
|
46
46
|
Step: string;
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { RestURL } from './rest_url';
|
|
13
13
|
import { Observable, ReplaySubject } from 'rxjs';
|
|
14
14
|
import { ApplicationMenu, ConstantValue } from '../model/common';
|
|
15
|
-
import { CanActivateFn, Router, Routes } from '@angular/router';
|
|
15
|
+
import { CanActivateFn, CanDeactivateFn, Router, Routes } from '@angular/router';
|
|
16
16
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../config';
|
|
17
17
|
import { BaseRestService } from './base_rest.service';
|
|
18
18
|
|
|
@@ -500,6 +500,15 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
500
500
|
);
|
|
501
501
|
};
|
|
502
502
|
|
|
503
|
+
// Confirms navigation away when the routed component reports unsaved changes.
|
|
504
|
+
// Opt in by implementing hasUnsavedChanges(): boolean on the component.
|
|
505
|
+
readonly canDeactivate: CanDeactivateFn<{ hasUnsavedChanges?: () => boolean }> = (component) => {
|
|
506
|
+
if (component && typeof component.hasUnsavedChanges === 'function' && component.hasUnsavedChanges()) {
|
|
507
|
+
return window.confirm('You have unsaved changes. Leave this page without saving?');
|
|
508
|
+
}
|
|
509
|
+
return true;
|
|
510
|
+
};
|
|
511
|
+
|
|
503
512
|
/** Override in subclasses to add project-specific routes */
|
|
504
513
|
protected buildExtraRoutes(data: ApplicationData): Routes {
|
|
505
514
|
if (this.guiConfig.extraRoutes) {
|
|
@@ -615,6 +624,13 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
615
624
|
}
|
|
616
625
|
}
|
|
617
626
|
|
|
627
|
+
// Guard navigation away from any component-backed page that
|
|
628
|
+
// tracks unsaved changes (opt-in via hasUnsavedChanges()).
|
|
629
|
+
for (const r of children) {
|
|
630
|
+
if ((r as { component?: unknown }).component) {
|
|
631
|
+
(r as { canDeactivate?: unknown[] }).canDeactivate = [this.canDeactivate];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
618
634
|
newRoutes.push({ path: menuPath, children });
|
|
619
635
|
}
|
|
620
636
|
|