ngx-dynamic-entity 0.0.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/esm2022/lib/field-types/checkbox-field.component.mjs +73 -0
- package/esm2022/lib/field-types/date-field.component.mjs +82 -0
- package/esm2022/lib/field-types/dropdown-field.component.mjs +83 -0
- package/esm2022/lib/field-types/entity-ref-field.component.mjs +117 -0
- package/esm2022/lib/field-types/multi-select-field.component.mjs +91 -0
- package/esm2022/lib/field-types/number-field.component.mjs +73 -0
- package/esm2022/lib/field-types/text-field.component.mjs +74 -0
- package/esm2022/lib/field-types/textarea-field.component.mjs +73 -0
- package/esm2022/lib/form/dynamic-field/dynamic-field.component.mjs +86 -0
- package/esm2022/lib/form/dynamic-form.component.mjs +167 -0
- package/esm2022/lib/providers/provide-ngx-dynamic-entity.mjs +23 -0
- package/esm2022/lib/services/config.service.mjs +58 -0
- package/esm2022/lib/services/entity-ref-registry.service.mjs +31 -0
- package/esm2022/lib/services/field-registry.service.mjs +50 -0
- package/esm2022/lib/services/hook-registry.service.mjs +26 -0
- package/esm2022/lib/services/rbac.service.mjs +52 -0
- package/esm2022/lib/services/validator-registry.service.mjs +49 -0
- package/esm2022/lib/services/version.service.mjs +40 -0
- package/esm2022/lib/table/dynamic-table.component.mjs +160 -0
- package/esm2022/lib/tokens/injection-tokens.mjs +16 -0
- package/esm2022/ngx-dynamic-entity.mjs +5 -0
- package/esm2022/public-api.mjs +26 -0
- package/fesm2022/ngx-dynamic-entity.mjs +1352 -0
- package/fesm2022/ngx-dynamic-entity.mjs.map +1 -0
- package/index.d.ts +5 -0
- package/lib/field-types/checkbox-field.component.d.ts +12 -0
- package/lib/field-types/date-field.component.d.ts +14 -0
- package/lib/field-types/dropdown-field.component.d.ts +13 -0
- package/lib/field-types/entity-ref-field.component.d.ts +26 -0
- package/lib/field-types/multi-select-field.component.d.ts +13 -0
- package/lib/field-types/number-field.component.d.ts +12 -0
- package/lib/field-types/text-field.component.d.ts +13 -0
- package/lib/field-types/textarea-field.component.d.ts +12 -0
- package/lib/form/dynamic-field/dynamic-field.component.d.ts +30 -0
- package/lib/form/dynamic-form.component.d.ts +52 -0
- package/lib/providers/provide-ngx-dynamic-entity.d.ts +29 -0
- package/lib/services/config.service.d.ts +25 -0
- package/lib/services/entity-ref-registry.service.d.ts +20 -0
- package/lib/services/field-registry.service.d.ts +22 -0
- package/lib/services/hook-registry.service.d.ts +12 -0
- package/lib/services/rbac.service.d.ts +32 -0
- package/lib/services/validator-registry.service.d.ts +19 -0
- package/lib/services/version.service.d.ts +26 -0
- package/lib/table/dynamic-table.component.d.ts +56 -0
- package/lib/tokens/injection-tokens.d.ts +16 -0
- package/package.json +37 -0
- package/public-api.d.ts +21 -0
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, makeEnvironmentProviders, inject, Injectable, Input, Component, signal, ViewContainerRef, ViewChild, EventEmitter, Output, computed } from '@angular/core';
|
|
3
|
+
import { HttpClient } from '@angular/common/http';
|
|
4
|
+
import { map, shareReplay } from 'rxjs/operators';
|
|
5
|
+
import * as i1 from '@angular/forms';
|
|
6
|
+
import { ReactiveFormsModule, Validators, FormBuilder } from '@angular/forms';
|
|
7
|
+
|
|
8
|
+
/** Base URL for the dynamic-entity-server API */
|
|
9
|
+
const DYNAMIC_ENTITY_API_URL = new InjectionToken('DYNAMIC_ENTITY_API_URL');
|
|
10
|
+
/** Roles that see XXXXXXXXX for masked fields (ADR-003) */
|
|
11
|
+
const MASKED_ROLES = new InjectionToken('MASKED_ROLES');
|
|
12
|
+
/** Migration strategy for the frontend — 'strict' | 'graceful' only (ADR-005, never 'auto') */
|
|
13
|
+
const MIGRATION_STRATEGY = new InjectionToken('MIGRATION_STRATEGY');
|
|
14
|
+
/** Registry: fieldType string → Angular component class */
|
|
15
|
+
const FIELD_TYPE_REGISTRY = new InjectionToken('FIELD_TYPE_REGISTRY');
|
|
16
|
+
/** Registry: entity key → async () => options[] loader function */
|
|
17
|
+
const ENTITY_REF_REGISTRY = new InjectionToken('ENTITY_REF_REGISTRY');
|
|
18
|
+
/** Registry: validator key → ValidatorFn */
|
|
19
|
+
const VALIDATOR_REGISTRY = new InjectionToken('VALIDATOR_REGISTRY');
|
|
20
|
+
/** Registry: hook key → async (data, context) => data */
|
|
21
|
+
const HOOK_REGISTRY = new InjectionToken('HOOK_REGISTRY');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Provider function for ngx-dynamic-entity.
|
|
25
|
+
* All options are optional — consumers provide only what they use (ISP).
|
|
26
|
+
*
|
|
27
|
+
* Usage in app.config.ts:
|
|
28
|
+
* provideNgxDynamicEntity({
|
|
29
|
+
* apiUrl: 'http://localhost:3000/api/entities',
|
|
30
|
+
* maskedRoles: ['IT_SUPPORT'],
|
|
31
|
+
* entityRefs: { clients: () => clientService.getOptions() },
|
|
32
|
+
* })
|
|
33
|
+
*/
|
|
34
|
+
const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
|
|
35
|
+
{ provide: DYNAMIC_ENTITY_API_URL, useValue: config.apiUrl ?? '' },
|
|
36
|
+
{ provide: MASKED_ROLES, useValue: config.maskedRoles ?? [] },
|
|
37
|
+
{ provide: MIGRATION_STRATEGY, useValue: config.migrationStrategy ?? 'graceful' },
|
|
38
|
+
{ provide: FIELD_TYPE_REGISTRY, useValue: new Map(Object.entries(config.fieldTypes ?? {})) },
|
|
39
|
+
{ provide: ENTITY_REF_REGISTRY, useValue: new Map(Object.entries(config.entityRefs ?? {})) },
|
|
40
|
+
{ provide: VALIDATOR_REGISTRY, useValue: new Map(Object.entries(config.validators ?? {})) },
|
|
41
|
+
{ provide: HOOK_REGISTRY, useValue: new Map(Object.entries(config.hooks ?? {})) },
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ConfigService — fetches entity configs from the dynamic-entity-server API.
|
|
46
|
+
* Uses shareReplay(1) to cache per config key — avoids duplicate HTTP requests.
|
|
47
|
+
* Following ONEHERMES smart service pattern: Observable$ + shareReplay.
|
|
48
|
+
*/
|
|
49
|
+
class ConfigService {
|
|
50
|
+
http = inject(HttpClient);
|
|
51
|
+
apiUrl = inject(DYNAMIC_ENTITY_API_URL, { optional: true }) ?? '';
|
|
52
|
+
cache = new Map();
|
|
53
|
+
/** Get a config by entity key. Cached via shareReplay(1). */
|
|
54
|
+
getConfig(entity) {
|
|
55
|
+
if (!this.cache.has(entity)) {
|
|
56
|
+
const config$ = this.http
|
|
57
|
+
.get(`${this.apiUrl}/config/${entity}`)
|
|
58
|
+
.pipe(map(res => res.data), // ADR-004: Success guarantees data
|
|
59
|
+
shareReplay(1));
|
|
60
|
+
this.cache.set(entity, config$);
|
|
61
|
+
}
|
|
62
|
+
return this.cache.get(entity);
|
|
63
|
+
}
|
|
64
|
+
/** List all configs */
|
|
65
|
+
listConfigs() {
|
|
66
|
+
return this.http
|
|
67
|
+
.get(`${this.apiUrl}/config`)
|
|
68
|
+
.pipe(map(res => res.data)); // ADR-004: Success guarantees data
|
|
69
|
+
}
|
|
70
|
+
/** Create a new config */
|
|
71
|
+
saveConfig(config) {
|
|
72
|
+
return this.http
|
|
73
|
+
.post(`${this.apiUrl}/config`, config)
|
|
74
|
+
.pipe(map(res => res.data));
|
|
75
|
+
}
|
|
76
|
+
/** Update an existing config */
|
|
77
|
+
updateConfig(entity, updates) {
|
|
78
|
+
return this.http
|
|
79
|
+
.put(`${this.apiUrl}/config/${entity}`, updates)
|
|
80
|
+
.pipe(map(res => {
|
|
81
|
+
this.invalidate(entity);
|
|
82
|
+
return res.data;
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
/** Invalidate cache for an entity (call after config update) */
|
|
86
|
+
invalidate(entity) {
|
|
87
|
+
this.cache.delete(entity);
|
|
88
|
+
}
|
|
89
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
90
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConfigService, providedIn: 'root' });
|
|
91
|
+
}
|
|
92
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConfigService, decorators: [{
|
|
93
|
+
type: Injectable,
|
|
94
|
+
args: [{ providedIn: 'root' }]
|
|
95
|
+
}] });
|
|
96
|
+
|
|
97
|
+
/** ADR-008: Shared field component contract — all 8 built-in types implement this. */
|
|
98
|
+
class TextFieldComponent {
|
|
99
|
+
field;
|
|
100
|
+
control;
|
|
101
|
+
language = 'en';
|
|
102
|
+
readonly = false;
|
|
103
|
+
masked = false;
|
|
104
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
105
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TextFieldComponent, isStandalone: true, selector: "ngx-text-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
106
|
+
<div class="ngx-field ngx-field--text" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
107
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
108
|
+
@if (masked) {
|
|
109
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
110
|
+
} @else if (readonly) {
|
|
111
|
+
<span class="ngx-field__value">{{ control.value }}</span>
|
|
112
|
+
} @else {
|
|
113
|
+
<input
|
|
114
|
+
class="ngx-field__input"
|
|
115
|
+
type="text"
|
|
116
|
+
[formControl]="$any(control)"
|
|
117
|
+
[placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
|
|
118
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
119
|
+
/>
|
|
120
|
+
@if (control.invalid && control.touched) {
|
|
121
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
</div>
|
|
125
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
126
|
+
}
|
|
127
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, decorators: [{
|
|
128
|
+
type: Component,
|
|
129
|
+
args: [{
|
|
130
|
+
selector: 'ngx-text-field',
|
|
131
|
+
standalone: true,
|
|
132
|
+
imports: [ReactiveFormsModule],
|
|
133
|
+
template: `
|
|
134
|
+
<div class="ngx-field ngx-field--text" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
135
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
136
|
+
@if (masked) {
|
|
137
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
138
|
+
} @else if (readonly) {
|
|
139
|
+
<span class="ngx-field__value">{{ control.value }}</span>
|
|
140
|
+
} @else {
|
|
141
|
+
<input
|
|
142
|
+
class="ngx-field__input"
|
|
143
|
+
type="text"
|
|
144
|
+
[formControl]="$any(control)"
|
|
145
|
+
[placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
|
|
146
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
147
|
+
/>
|
|
148
|
+
@if (control.invalid && control.touched) {
|
|
149
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
</div>
|
|
153
|
+
`,
|
|
154
|
+
}]
|
|
155
|
+
}], propDecorators: { field: [{
|
|
156
|
+
type: Input
|
|
157
|
+
}], control: [{
|
|
158
|
+
type: Input
|
|
159
|
+
}], language: [{
|
|
160
|
+
type: Input
|
|
161
|
+
}], readonly: [{
|
|
162
|
+
type: Input
|
|
163
|
+
}], masked: [{
|
|
164
|
+
type: Input
|
|
165
|
+
}] } });
|
|
166
|
+
|
|
167
|
+
class TextareaFieldComponent {
|
|
168
|
+
field;
|
|
169
|
+
control;
|
|
170
|
+
language = 'en';
|
|
171
|
+
readonly = false;
|
|
172
|
+
masked = false;
|
|
173
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextareaFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
174
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TextareaFieldComponent, isStandalone: true, selector: "ngx-textarea-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
175
|
+
<div class="ngx-field ngx-field--textarea" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
176
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
177
|
+
@if (masked) {
|
|
178
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
179
|
+
} @else if (readonly) {
|
|
180
|
+
<span class="ngx-field__value">{{ control.value }}</span>
|
|
181
|
+
} @else {
|
|
182
|
+
<textarea
|
|
183
|
+
class="ngx-field__input"
|
|
184
|
+
[formControl]="$any(control)"
|
|
185
|
+
[placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
|
|
186
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
187
|
+
rows="3"
|
|
188
|
+
></textarea>
|
|
189
|
+
@if (control.invalid && control.touched) {
|
|
190
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
</div>
|
|
194
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
195
|
+
}
|
|
196
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextareaFieldComponent, decorators: [{
|
|
197
|
+
type: Component,
|
|
198
|
+
args: [{
|
|
199
|
+
selector: 'ngx-textarea-field',
|
|
200
|
+
standalone: true,
|
|
201
|
+
imports: [ReactiveFormsModule],
|
|
202
|
+
template: `
|
|
203
|
+
<div class="ngx-field ngx-field--textarea" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
204
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
205
|
+
@if (masked) {
|
|
206
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
207
|
+
} @else if (readonly) {
|
|
208
|
+
<span class="ngx-field__value">{{ control.value }}</span>
|
|
209
|
+
} @else {
|
|
210
|
+
<textarea
|
|
211
|
+
class="ngx-field__input"
|
|
212
|
+
[formControl]="$any(control)"
|
|
213
|
+
[placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
|
|
214
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
215
|
+
rows="3"
|
|
216
|
+
></textarea>
|
|
217
|
+
@if (control.invalid && control.touched) {
|
|
218
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
</div>
|
|
222
|
+
`,
|
|
223
|
+
}]
|
|
224
|
+
}], propDecorators: { field: [{
|
|
225
|
+
type: Input
|
|
226
|
+
}], control: [{
|
|
227
|
+
type: Input
|
|
228
|
+
}], language: [{
|
|
229
|
+
type: Input
|
|
230
|
+
}], readonly: [{
|
|
231
|
+
type: Input
|
|
232
|
+
}], masked: [{
|
|
233
|
+
type: Input
|
|
234
|
+
}] } });
|
|
235
|
+
|
|
236
|
+
class NumberFieldComponent {
|
|
237
|
+
field;
|
|
238
|
+
control;
|
|
239
|
+
language = 'en';
|
|
240
|
+
readonly = false;
|
|
241
|
+
masked = false;
|
|
242
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
243
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: NumberFieldComponent, isStandalone: true, selector: "ngx-number-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
244
|
+
<div class="ngx-field ngx-field--number" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
245
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
246
|
+
@if (masked) {
|
|
247
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
248
|
+
} @else if (readonly) {
|
|
249
|
+
<span class="ngx-field__value">{{ control.value }}</span>
|
|
250
|
+
} @else {
|
|
251
|
+
<input
|
|
252
|
+
class="ngx-field__input"
|
|
253
|
+
type="number"
|
|
254
|
+
[formControl]="$any(control)"
|
|
255
|
+
[placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
|
|
256
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
257
|
+
/>
|
|
258
|
+
@if (control.invalid && control.touched) {
|
|
259
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
</div>
|
|
263
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
264
|
+
}
|
|
265
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, decorators: [{
|
|
266
|
+
type: Component,
|
|
267
|
+
args: [{
|
|
268
|
+
selector: 'ngx-number-field',
|
|
269
|
+
standalone: true,
|
|
270
|
+
imports: [ReactiveFormsModule],
|
|
271
|
+
template: `
|
|
272
|
+
<div class="ngx-field ngx-field--number" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
273
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
274
|
+
@if (masked) {
|
|
275
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
276
|
+
} @else if (readonly) {
|
|
277
|
+
<span class="ngx-field__value">{{ control.value }}</span>
|
|
278
|
+
} @else {
|
|
279
|
+
<input
|
|
280
|
+
class="ngx-field__input"
|
|
281
|
+
type="number"
|
|
282
|
+
[formControl]="$any(control)"
|
|
283
|
+
[placeholder]="field.placeholder?.[language] || field.placeholder?.['en'] || ''"
|
|
284
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
285
|
+
/>
|
|
286
|
+
@if (control.invalid && control.touched) {
|
|
287
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
</div>
|
|
291
|
+
`,
|
|
292
|
+
}]
|
|
293
|
+
}], propDecorators: { field: [{
|
|
294
|
+
type: Input
|
|
295
|
+
}], control: [{
|
|
296
|
+
type: Input
|
|
297
|
+
}], language: [{
|
|
298
|
+
type: Input
|
|
299
|
+
}], readonly: [{
|
|
300
|
+
type: Input
|
|
301
|
+
}], masked: [{
|
|
302
|
+
type: Input
|
|
303
|
+
}] } });
|
|
304
|
+
|
|
305
|
+
class CheckboxFieldComponent {
|
|
306
|
+
field;
|
|
307
|
+
control;
|
|
308
|
+
language = 'en';
|
|
309
|
+
readonly = false;
|
|
310
|
+
masked = false;
|
|
311
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CheckboxFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
312
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: CheckboxFieldComponent, isStandalone: true, selector: "ngx-checkbox-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
313
|
+
<div class="ngx-field ngx-field--checkbox" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
314
|
+
@if (masked) {
|
|
315
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
316
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
317
|
+
} @else if (readonly) {
|
|
318
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
319
|
+
<span class="ngx-field__value">{{ control.value ? 'Yes' : 'No' }}</span>
|
|
320
|
+
} @else {
|
|
321
|
+
<label class="ngx-field__label">
|
|
322
|
+
<input
|
|
323
|
+
class="ngx-field__input"
|
|
324
|
+
type="checkbox"
|
|
325
|
+
[formControl]="$any(control)"
|
|
326
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
327
|
+
/>
|
|
328
|
+
{{ field.label[language] || field.label['en'] }}
|
|
329
|
+
</label>
|
|
330
|
+
}
|
|
331
|
+
</div>
|
|
332
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
333
|
+
}
|
|
334
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CheckboxFieldComponent, decorators: [{
|
|
335
|
+
type: Component,
|
|
336
|
+
args: [{
|
|
337
|
+
selector: 'ngx-checkbox-field',
|
|
338
|
+
standalone: true,
|
|
339
|
+
imports: [ReactiveFormsModule],
|
|
340
|
+
template: `
|
|
341
|
+
<div class="ngx-field ngx-field--checkbox" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
342
|
+
@if (masked) {
|
|
343
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
344
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
345
|
+
} @else if (readonly) {
|
|
346
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
347
|
+
<span class="ngx-field__value">{{ control.value ? 'Yes' : 'No' }}</span>
|
|
348
|
+
} @else {
|
|
349
|
+
<label class="ngx-field__label">
|
|
350
|
+
<input
|
|
351
|
+
class="ngx-field__input"
|
|
352
|
+
type="checkbox"
|
|
353
|
+
[formControl]="$any(control)"
|
|
354
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
355
|
+
/>
|
|
356
|
+
{{ field.label[language] || field.label['en'] }}
|
|
357
|
+
</label>
|
|
358
|
+
}
|
|
359
|
+
</div>
|
|
360
|
+
`,
|
|
361
|
+
}]
|
|
362
|
+
}], propDecorators: { field: [{
|
|
363
|
+
type: Input
|
|
364
|
+
}], control: [{
|
|
365
|
+
type: Input
|
|
366
|
+
}], language: [{
|
|
367
|
+
type: Input
|
|
368
|
+
}], readonly: [{
|
|
369
|
+
type: Input
|
|
370
|
+
}], masked: [{
|
|
371
|
+
type: Input
|
|
372
|
+
}] } });
|
|
373
|
+
|
|
374
|
+
class DateFieldComponent {
|
|
375
|
+
field;
|
|
376
|
+
control;
|
|
377
|
+
language = 'en';
|
|
378
|
+
readonly = false;
|
|
379
|
+
masked = false;
|
|
380
|
+
/** ISO 8601 date → locale display string (ONEHERMES convention: always UTC, display in user locale) */
|
|
381
|
+
formatDate(value) {
|
|
382
|
+
if (!value)
|
|
383
|
+
return '—';
|
|
384
|
+
try {
|
|
385
|
+
return new Date(value).toLocaleDateString();
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return value;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
392
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DateFieldComponent, isStandalone: true, selector: "ngx-date-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
393
|
+
<div class="ngx-field ngx-field--date" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
394
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
395
|
+
@if (masked) {
|
|
396
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
397
|
+
} @else if (readonly) {
|
|
398
|
+
<span class="ngx-field__value">{{ formatDate(control.value) }}</span>
|
|
399
|
+
} @else {
|
|
400
|
+
<input
|
|
401
|
+
class="ngx-field__input"
|
|
402
|
+
type="date"
|
|
403
|
+
[formControl]="$any(control)"
|
|
404
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
405
|
+
/>
|
|
406
|
+
@if (control.invalid && control.touched) {
|
|
407
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
</div>
|
|
411
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
412
|
+
}
|
|
413
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateFieldComponent, decorators: [{
|
|
414
|
+
type: Component,
|
|
415
|
+
args: [{
|
|
416
|
+
selector: 'ngx-date-field',
|
|
417
|
+
standalone: true,
|
|
418
|
+
imports: [ReactiveFormsModule],
|
|
419
|
+
template: `
|
|
420
|
+
<div class="ngx-field ngx-field--date" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
421
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
422
|
+
@if (masked) {
|
|
423
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
424
|
+
} @else if (readonly) {
|
|
425
|
+
<span class="ngx-field__value">{{ formatDate(control.value) }}</span>
|
|
426
|
+
} @else {
|
|
427
|
+
<input
|
|
428
|
+
class="ngx-field__input"
|
|
429
|
+
type="date"
|
|
430
|
+
[formControl]="$any(control)"
|
|
431
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
432
|
+
/>
|
|
433
|
+
@if (control.invalid && control.touched) {
|
|
434
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
</div>
|
|
438
|
+
`,
|
|
439
|
+
}]
|
|
440
|
+
}], propDecorators: { field: [{
|
|
441
|
+
type: Input
|
|
442
|
+
}], control: [{
|
|
443
|
+
type: Input
|
|
444
|
+
}], language: [{
|
|
445
|
+
type: Input
|
|
446
|
+
}], readonly: [{
|
|
447
|
+
type: Input
|
|
448
|
+
}], masked: [{
|
|
449
|
+
type: Input
|
|
450
|
+
}] } });
|
|
451
|
+
|
|
452
|
+
class DropdownFieldComponent {
|
|
453
|
+
field;
|
|
454
|
+
control;
|
|
455
|
+
language = 'en';
|
|
456
|
+
readonly = false;
|
|
457
|
+
masked = false;
|
|
458
|
+
getLabel(value) {
|
|
459
|
+
const option = (this.field.options || []).find(o => o.value === value);
|
|
460
|
+
return option ? (option.label[this.language] || option.label['en'] || String(value)) : (value ?? '—');
|
|
461
|
+
}
|
|
462
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
463
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DropdownFieldComponent, isStandalone: true, selector: "ngx-dropdown-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
464
|
+
<div class="ngx-field ngx-field--dropdown" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
465
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
466
|
+
@if (masked) {
|
|
467
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
468
|
+
} @else if (readonly) {
|
|
469
|
+
<span class="ngx-field__value">{{ getLabel(control.value) }}</span>
|
|
470
|
+
} @else {
|
|
471
|
+
<select
|
|
472
|
+
class="ngx-field__input"
|
|
473
|
+
[formControl]="$any(control)"
|
|
474
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
475
|
+
>
|
|
476
|
+
<option value="">{{ field.placeholder?.[language] || field.placeholder?.['en'] || 'Select...' }}</option>
|
|
477
|
+
@for (option of field.options || []; track option.value) {
|
|
478
|
+
<option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
|
|
479
|
+
}
|
|
480
|
+
</select>
|
|
481
|
+
@if (control.invalid && control.touched) {
|
|
482
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
</div>
|
|
486
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
487
|
+
}
|
|
488
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, decorators: [{
|
|
489
|
+
type: Component,
|
|
490
|
+
args: [{
|
|
491
|
+
selector: 'ngx-dropdown-field',
|
|
492
|
+
standalone: true,
|
|
493
|
+
imports: [ReactiveFormsModule],
|
|
494
|
+
template: `
|
|
495
|
+
<div class="ngx-field ngx-field--dropdown" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
496
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
497
|
+
@if (masked) {
|
|
498
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
499
|
+
} @else if (readonly) {
|
|
500
|
+
<span class="ngx-field__value">{{ getLabel(control.value) }}</span>
|
|
501
|
+
} @else {
|
|
502
|
+
<select
|
|
503
|
+
class="ngx-field__input"
|
|
504
|
+
[formControl]="$any(control)"
|
|
505
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
506
|
+
>
|
|
507
|
+
<option value="">{{ field.placeholder?.[language] || field.placeholder?.['en'] || 'Select...' }}</option>
|
|
508
|
+
@for (option of field.options || []; track option.value) {
|
|
509
|
+
<option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
|
|
510
|
+
}
|
|
511
|
+
</select>
|
|
512
|
+
@if (control.invalid && control.touched) {
|
|
513
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
</div>
|
|
517
|
+
`,
|
|
518
|
+
}]
|
|
519
|
+
}], propDecorators: { field: [{
|
|
520
|
+
type: Input
|
|
521
|
+
}], control: [{
|
|
522
|
+
type: Input
|
|
523
|
+
}], language: [{
|
|
524
|
+
type: Input
|
|
525
|
+
}], readonly: [{
|
|
526
|
+
type: Input
|
|
527
|
+
}], masked: [{
|
|
528
|
+
type: Input
|
|
529
|
+
}] } });
|
|
530
|
+
|
|
531
|
+
class MultiSelectFieldComponent {
|
|
532
|
+
field;
|
|
533
|
+
control;
|
|
534
|
+
language = 'en';
|
|
535
|
+
readonly = false;
|
|
536
|
+
masked = false;
|
|
537
|
+
getLabels(values) {
|
|
538
|
+
if (!Array.isArray(values) || !values.length)
|
|
539
|
+
return '—';
|
|
540
|
+
return values
|
|
541
|
+
.map(v => {
|
|
542
|
+
const opt = (this.field.options || []).find(o => o.value === v);
|
|
543
|
+
return opt ? (opt.label[this.language] || opt.label['en']) : String(v);
|
|
544
|
+
})
|
|
545
|
+
.join(', ');
|
|
546
|
+
}
|
|
547
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MultiSelectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
548
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: MultiSelectFieldComponent, isStandalone: true, selector: "ngx-multi-select-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
549
|
+
<div class="ngx-field ngx-field--multiSelect" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
550
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
551
|
+
@if (masked) {
|
|
552
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
553
|
+
} @else if (readonly) {
|
|
554
|
+
<span class="ngx-field__value">{{ getLabels(control.value) }}</span>
|
|
555
|
+
} @else {
|
|
556
|
+
<select
|
|
557
|
+
class="ngx-field__input"
|
|
558
|
+
[formControl]="$any(control)"
|
|
559
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
560
|
+
multiple
|
|
561
|
+
size="4"
|
|
562
|
+
>
|
|
563
|
+
@for (option of field.options || []; track option.value) {
|
|
564
|
+
<option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
|
|
565
|
+
}
|
|
566
|
+
</select>
|
|
567
|
+
@if (control.invalid && control.touched) {
|
|
568
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
</div>
|
|
572
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
573
|
+
}
|
|
574
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MultiSelectFieldComponent, decorators: [{
|
|
575
|
+
type: Component,
|
|
576
|
+
args: [{
|
|
577
|
+
selector: 'ngx-multi-select-field',
|
|
578
|
+
standalone: true,
|
|
579
|
+
imports: [ReactiveFormsModule],
|
|
580
|
+
template: `
|
|
581
|
+
<div class="ngx-field ngx-field--multiSelect" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
582
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
583
|
+
@if (masked) {
|
|
584
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
585
|
+
} @else if (readonly) {
|
|
586
|
+
<span class="ngx-field__value">{{ getLabels(control.value) }}</span>
|
|
587
|
+
} @else {
|
|
588
|
+
<select
|
|
589
|
+
class="ngx-field__input"
|
|
590
|
+
[formControl]="$any(control)"
|
|
591
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
592
|
+
multiple
|
|
593
|
+
size="4"
|
|
594
|
+
>
|
|
595
|
+
@for (option of field.options || []; track option.value) {
|
|
596
|
+
<option [value]="option.value">{{ option.label[language] || option.label['en'] }}</option>
|
|
597
|
+
}
|
|
598
|
+
</select>
|
|
599
|
+
@if (control.invalid && control.touched) {
|
|
600
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
</div>
|
|
604
|
+
`,
|
|
605
|
+
}]
|
|
606
|
+
}], propDecorators: { field: [{
|
|
607
|
+
type: Input
|
|
608
|
+
}], control: [{
|
|
609
|
+
type: Input
|
|
610
|
+
}], language: [{
|
|
611
|
+
type: Input
|
|
612
|
+
}], readonly: [{
|
|
613
|
+
type: Input
|
|
614
|
+
}], masked: [{
|
|
615
|
+
type: Input
|
|
616
|
+
}] } });
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* EntityRefRegistryService — resolves entity-ref option loader functions.
|
|
620
|
+
* ADR-006: Entity-ref options come ONLY from this registry.
|
|
621
|
+
* Never pass loaders via @Input() on field components.
|
|
622
|
+
*
|
|
623
|
+
* Consumers register loaders via provideNgxDynamicEntity({ entityRefs: { clients: () => [...] } }).
|
|
624
|
+
* EntityRefFieldComponent injects this service and calls resolve(field.component ?? field.id).
|
|
625
|
+
*/
|
|
626
|
+
class EntityRefRegistryService {
|
|
627
|
+
registry = inject(ENTITY_REF_REGISTRY, { optional: true }) ?? new Map();
|
|
628
|
+
/**
|
|
629
|
+
* Get the option loader function for an entity key.
|
|
630
|
+
* @returns Async function that returns options array, or null if not registered.
|
|
631
|
+
*/
|
|
632
|
+
resolve(entityKey) {
|
|
633
|
+
return this.registry.get(entityKey) ?? null;
|
|
634
|
+
}
|
|
635
|
+
has(entityKey) {
|
|
636
|
+
return this.registry.has(entityKey);
|
|
637
|
+
}
|
|
638
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
639
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefRegistryService, providedIn: 'root' });
|
|
640
|
+
}
|
|
641
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefRegistryService, decorators: [{
|
|
642
|
+
type: Injectable,
|
|
643
|
+
args: [{ providedIn: 'root' }]
|
|
644
|
+
}] });
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* EntityRefFieldComponent — renders a dropdown populated by a loader from EntityRefRegistryService.
|
|
648
|
+
* ADR-006: Options come ONLY from the registry. Never via @Input() loader.
|
|
649
|
+
* Uses field.component (if set) or field.id as the registry lookup key.
|
|
650
|
+
*/
|
|
651
|
+
class EntityRefFieldComponent {
|
|
652
|
+
field;
|
|
653
|
+
control;
|
|
654
|
+
language = 'en';
|
|
655
|
+
readonly = false;
|
|
656
|
+
masked = false;
|
|
657
|
+
entityRefRegistry = inject(EntityRefRegistryService);
|
|
658
|
+
options = signal([]);
|
|
659
|
+
loading = signal(false);
|
|
660
|
+
async ngOnInit() {
|
|
661
|
+
if (this.masked)
|
|
662
|
+
return; // No need to load options if masked
|
|
663
|
+
const entityKey = this.field.component ?? this.field.id;
|
|
664
|
+
const loader = this.entityRefRegistry.resolve(entityKey);
|
|
665
|
+
if (!loader)
|
|
666
|
+
return;
|
|
667
|
+
this.loading.set(true);
|
|
668
|
+
try {
|
|
669
|
+
const raw = await loader();
|
|
670
|
+
this.options.set(raw);
|
|
671
|
+
}
|
|
672
|
+
finally {
|
|
673
|
+
this.loading.set(false);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
getLabel(value) {
|
|
677
|
+
const option = this.options().find(o => o.value === value);
|
|
678
|
+
return option?.label ?? (value ?? '—');
|
|
679
|
+
}
|
|
680
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
681
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: EntityRefFieldComponent, isStandalone: true, selector: "ngx-entity-ref-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
|
|
682
|
+
<div class="ngx-field ngx-field--entity-ref" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
683
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
684
|
+
@if (masked) {
|
|
685
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
686
|
+
} @else if (readonly) {
|
|
687
|
+
<span class="ngx-field__value">{{ getLabel(control.value) }}</span>
|
|
688
|
+
} @else {
|
|
689
|
+
@if (loading()) {
|
|
690
|
+
<span class="ngx-field__value">Loading...</span>
|
|
691
|
+
} @else {
|
|
692
|
+
<select
|
|
693
|
+
class="ngx-field__input"
|
|
694
|
+
[formControl]="$any(control)"
|
|
695
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
696
|
+
>
|
|
697
|
+
<option value="">{{ field.placeholder?.[language] || 'Select...' }}</option>
|
|
698
|
+
@for (option of options(); track option.value) {
|
|
699
|
+
<option [value]="option.value">{{ option.label }}</option>
|
|
700
|
+
}
|
|
701
|
+
</select>
|
|
702
|
+
}
|
|
703
|
+
@if (control.invalid && control.touched) {
|
|
704
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
</div>
|
|
708
|
+
`, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
|
|
709
|
+
}
|
|
710
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefFieldComponent, decorators: [{
|
|
711
|
+
type: Component,
|
|
712
|
+
args: [{
|
|
713
|
+
selector: 'ngx-entity-ref-field',
|
|
714
|
+
standalone: true,
|
|
715
|
+
imports: [ReactiveFormsModule],
|
|
716
|
+
template: `
|
|
717
|
+
<div class="ngx-field ngx-field--entity-ref" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
|
|
718
|
+
<label class="ngx-field__label">{{ field.label[language] || field.label['en'] }}</label>
|
|
719
|
+
@if (masked) {
|
|
720
|
+
<span class="ngx-field__value ngx-field__value--masked">XXXXXXXXX</span>
|
|
721
|
+
} @else if (readonly) {
|
|
722
|
+
<span class="ngx-field__value">{{ getLabel(control.value) }}</span>
|
|
723
|
+
} @else {
|
|
724
|
+
@if (loading()) {
|
|
725
|
+
<span class="ngx-field__value">Loading...</span>
|
|
726
|
+
} @else {
|
|
727
|
+
<select
|
|
728
|
+
class="ngx-field__input"
|
|
729
|
+
[formControl]="$any(control)"
|
|
730
|
+
[attr.disabled]="field.disabled ? true : null"
|
|
731
|
+
>
|
|
732
|
+
<option value="">{{ field.placeholder?.[language] || 'Select...' }}</option>
|
|
733
|
+
@for (option of options(); track option.value) {
|
|
734
|
+
<option [value]="option.value">{{ option.label }}</option>
|
|
735
|
+
}
|
|
736
|
+
</select>
|
|
737
|
+
}
|
|
738
|
+
@if (control.invalid && control.touched) {
|
|
739
|
+
<span class="ngx-field__error">This field has an error</span>
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
</div>
|
|
743
|
+
`,
|
|
744
|
+
}]
|
|
745
|
+
}], propDecorators: { field: [{
|
|
746
|
+
type: Input
|
|
747
|
+
}], control: [{
|
|
748
|
+
type: Input
|
|
749
|
+
}], language: [{
|
|
750
|
+
type: Input
|
|
751
|
+
}], readonly: [{
|
|
752
|
+
type: Input
|
|
753
|
+
}], masked: [{
|
|
754
|
+
type: Input
|
|
755
|
+
}] } });
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* FieldRegistryService — resolves field type strings to Angular component classes.
|
|
759
|
+
* Open/Closed: open for extension via consumer fieldTypes config, closed for modification.
|
|
760
|
+
* DynamicFieldComponent uses this to mount the right component via createComponent().
|
|
761
|
+
*/
|
|
762
|
+
class FieldRegistryService {
|
|
763
|
+
consumerRegistry = inject(FIELD_TYPE_REGISTRY, { optional: true }) ?? new Map();
|
|
764
|
+
/** Built-in field type → component map. Never remove an entry. */
|
|
765
|
+
builtInRegistry = new Map([
|
|
766
|
+
['text', TextFieldComponent],
|
|
767
|
+
['textarea', TextareaFieldComponent],
|
|
768
|
+
['number', NumberFieldComponent],
|
|
769
|
+
['checkbox', CheckboxFieldComponent],
|
|
770
|
+
['date', DateFieldComponent],
|
|
771
|
+
['dropdown', DropdownFieldComponent],
|
|
772
|
+
['multiSelect', MultiSelectFieldComponent],
|
|
773
|
+
['entity-ref', EntityRefFieldComponent],
|
|
774
|
+
]);
|
|
775
|
+
/**
|
|
776
|
+
* Resolve a field type string to a component class.
|
|
777
|
+
* Consumer registry takes precedence over built-ins (open for extension).
|
|
778
|
+
* Returns null if type is unknown — caller must handle gracefully.
|
|
779
|
+
*/
|
|
780
|
+
resolve(fieldType) {
|
|
781
|
+
return this.consumerRegistry.get(fieldType) ?? this.builtInRegistry.get(fieldType) ?? null;
|
|
782
|
+
}
|
|
783
|
+
/** Check if a field type has a registered component */
|
|
784
|
+
has(fieldType) {
|
|
785
|
+
return this.consumerRegistry.has(fieldType) || this.builtInRegistry.has(fieldType);
|
|
786
|
+
}
|
|
787
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
788
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRegistryService, providedIn: 'root' });
|
|
789
|
+
}
|
|
790
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRegistryService, decorators: [{
|
|
791
|
+
type: Injectable,
|
|
792
|
+
args: [{ providedIn: 'root' }]
|
|
793
|
+
}] });
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* HookRegistryService — stores and runs Angular-side pre/post hooks.
|
|
797
|
+
* Consumers register hooks via provideNgxDynamicEntity({ hooks: { 'clients:beforeSave': fn } }).
|
|
798
|
+
*/
|
|
799
|
+
class HookRegistryService {
|
|
800
|
+
registry = inject(HOOK_REGISTRY, { optional: true }) ?? new Map();
|
|
801
|
+
has(key) {
|
|
802
|
+
return this.registry.has(key);
|
|
803
|
+
}
|
|
804
|
+
async run(key, data, context = {}) {
|
|
805
|
+
const hook = this.registry.get(key);
|
|
806
|
+
if (!hook)
|
|
807
|
+
return data;
|
|
808
|
+
return hook(data, context);
|
|
809
|
+
}
|
|
810
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
811
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookRegistryService, providedIn: 'root' });
|
|
812
|
+
}
|
|
813
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HookRegistryService, decorators: [{
|
|
814
|
+
type: Injectable,
|
|
815
|
+
args: [{ providedIn: 'root' }]
|
|
816
|
+
}] });
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* ValidatorRegistryService — resolves validator key strings to Angular ValidatorFns.
|
|
820
|
+
* Built-in validators: 'required', 'email', 'min:N', 'max:N', 'minLength:N', 'maxLength:N'.
|
|
821
|
+
* Consumers can register custom validators via provideNgxDynamicEntity({ validators: {...} }).
|
|
822
|
+
*/
|
|
823
|
+
class ValidatorRegistryService {
|
|
824
|
+
consumerRegistry = inject(VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
|
|
825
|
+
/**
|
|
826
|
+
* Resolve a validator key to a ValidatorFn.
|
|
827
|
+
* Supports parameterized built-ins like 'min:0', 'maxLength:255'.
|
|
828
|
+
*/
|
|
829
|
+
resolve(validatorKey) {
|
|
830
|
+
// Consumer validators take precedence
|
|
831
|
+
if (this.consumerRegistry.has(validatorKey)) {
|
|
832
|
+
return this.consumerRegistry.get(validatorKey);
|
|
833
|
+
}
|
|
834
|
+
// Built-in validators
|
|
835
|
+
if (validatorKey === 'required')
|
|
836
|
+
return Validators.required;
|
|
837
|
+
if (validatorKey === 'email')
|
|
838
|
+
return Validators.email;
|
|
839
|
+
const [name, param] = validatorKey.split(':');
|
|
840
|
+
const value = parseFloat(param);
|
|
841
|
+
if (name === 'min' && !isNaN(value))
|
|
842
|
+
return Validators.min(value);
|
|
843
|
+
if (name === 'max' && !isNaN(value))
|
|
844
|
+
return Validators.max(value);
|
|
845
|
+
if (name === 'minLength' && !isNaN(value))
|
|
846
|
+
return Validators.minLength(value);
|
|
847
|
+
if (name === 'maxLength' && !isNaN(value))
|
|
848
|
+
return Validators.maxLength(value);
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
/** Resolve multiple validator keys into a ValidatorFn[] */
|
|
852
|
+
resolveAll(validatorKeys = []) {
|
|
853
|
+
return validatorKeys.map(k => this.resolve(k)).filter((v) => v !== null);
|
|
854
|
+
}
|
|
855
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
856
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
|
|
857
|
+
}
|
|
858
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, decorators: [{
|
|
859
|
+
type: Injectable,
|
|
860
|
+
args: [{ providedIn: 'root' }]
|
|
861
|
+
}] });
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* VersionService — checks config version staleness and migration strategy.
|
|
865
|
+
* ADR-005: Frontend strategy is 'strict' | 'graceful' only. Never 'auto'.
|
|
866
|
+
*/
|
|
867
|
+
class VersionService {
|
|
868
|
+
strategy = inject(MIGRATION_STRATEGY, { optional: true }) ?? 'graceful';
|
|
869
|
+
/**
|
|
870
|
+
* Check if a record is stale compared to the current config version.
|
|
871
|
+
* Returns true if the record needs migration.
|
|
872
|
+
*/
|
|
873
|
+
needsMigration(record, config) {
|
|
874
|
+
if (!record._configVersion)
|
|
875
|
+
return false;
|
|
876
|
+
return record._configVersion < config.version || !!record._needsMigration;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Get the effective migration strategy for frontend use.
|
|
880
|
+
* Only 'strict' or 'graceful' — never 'auto' (ADR-005).
|
|
881
|
+
*/
|
|
882
|
+
getStrategy() {
|
|
883
|
+
return this.strategy === 'strict' ? 'strict' : 'graceful';
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Determine if form submission should be blocked for a stale record.
|
|
887
|
+
* 'strict' → block; 'graceful' → allow with warning.
|
|
888
|
+
*/
|
|
889
|
+
shouldBlockSubmit(record, config) {
|
|
890
|
+
return this.getStrategy() === 'strict' && this.needsMigration(record, config);
|
|
891
|
+
}
|
|
892
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VersionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
893
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VersionService, providedIn: 'root' });
|
|
894
|
+
}
|
|
895
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: VersionService, decorators: [{
|
|
896
|
+
type: Injectable,
|
|
897
|
+
args: [{ providedIn: 'root' }]
|
|
898
|
+
}] });
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* RbacService — ALL permission checks and masking logic live here.
|
|
902
|
+
* CRITICAL SYNC: resolveEffectiveMask() must be identical to rbac.utils.js in dynamic-entity-server.
|
|
903
|
+
* Changing one without the other is a critical production bug (ADR-003).
|
|
904
|
+
*/
|
|
905
|
+
class RbacService {
|
|
906
|
+
maskedRoles = inject(MASKED_ROLES, { optional: true }) ?? [];
|
|
907
|
+
/**
|
|
908
|
+
* Check if any of the user's roles satisfy the required roles.
|
|
909
|
+
* hasPermission(roles, undefined) → true (no restriction).
|
|
910
|
+
*/
|
|
911
|
+
hasPermission(userRoles, requiredRoles) {
|
|
912
|
+
if (!requiredRoles?.length)
|
|
913
|
+
return true;
|
|
914
|
+
return userRoles.some(r => requiredRoles.includes(r));
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* ADR-003: 3-level OR resolution.
|
|
918
|
+
* MUST match rbac.utils.js resolveEffectiveMask() exactly.
|
|
919
|
+
*/
|
|
920
|
+
resolveEffectiveMask(formMask, tabMask, fieldMask) {
|
|
921
|
+
return !!(formMask || tabMask || fieldMask);
|
|
922
|
+
}
|
|
923
|
+
/** Check if the user's roles include any masked role */
|
|
924
|
+
isUserMaskedRole(userRoles) {
|
|
925
|
+
return this.maskedRoles.some(r => userRoles.includes(r));
|
|
926
|
+
}
|
|
927
|
+
/** Determine if a specific field should be masked for this user */
|
|
928
|
+
shouldMaskField(field, tab, config, userRoles) {
|
|
929
|
+
if (!this.isUserMaskedRole(userRoles))
|
|
930
|
+
return false;
|
|
931
|
+
return this.resolveEffectiveMask(config.maskData, tab?.maskData, field.maskData);
|
|
932
|
+
}
|
|
933
|
+
/** Get view/edit/delete permissions for a config */
|
|
934
|
+
getPermissions(config, userRoles) {
|
|
935
|
+
return {
|
|
936
|
+
canView: this.hasPermission(userRoles, config.permissions?.view),
|
|
937
|
+
canEdit: this.hasPermission(userRoles, config.permissions?.edit),
|
|
938
|
+
canDelete: this.hasPermission(userRoles, config.permissions?.delete),
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RbacService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
942
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RbacService, providedIn: 'root' });
|
|
943
|
+
}
|
|
944
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RbacService, decorators: [{
|
|
945
|
+
type: Injectable,
|
|
946
|
+
args: [{ providedIn: 'root' }]
|
|
947
|
+
}] });
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* DynamicFieldComponent — mounts the correct field component for a given FieldConfig.
|
|
951
|
+
* Uses ViewContainerRef.createComponent() and passes inputs via setInput() (ADR-008).
|
|
952
|
+
* Never add field-type-specific logic here — this component must remain generic.
|
|
953
|
+
*
|
|
954
|
+
* The 5 inputs (field, control, language, readonly, masked) are passed to every mounted
|
|
955
|
+
* component uniformly — no special-casing per type.
|
|
956
|
+
*/
|
|
957
|
+
class DynamicFieldComponent {
|
|
958
|
+
field;
|
|
959
|
+
control;
|
|
960
|
+
config;
|
|
961
|
+
language = 'en';
|
|
962
|
+
readonly = false;
|
|
963
|
+
userRoles = [];
|
|
964
|
+
fieldHost;
|
|
965
|
+
fieldRegistry = inject(FieldRegistryService);
|
|
966
|
+
rbacService = inject(RbacService);
|
|
967
|
+
mountedComponent = null;
|
|
968
|
+
ngOnChanges(_changes) {
|
|
969
|
+
this.mountField();
|
|
970
|
+
}
|
|
971
|
+
mountField() {
|
|
972
|
+
if (!this.field || !this.control)
|
|
973
|
+
return;
|
|
974
|
+
const ComponentClass = this.fieldRegistry.resolve(this.field.type);
|
|
975
|
+
if (!ComponentClass)
|
|
976
|
+
return; // Unknown field type — render nothing
|
|
977
|
+
// Determine masking for this specific field
|
|
978
|
+
const tab = (this.config?.tabs || []).find(t => t.id === this.field.tab);
|
|
979
|
+
const masked = this.rbacService.shouldMaskField(this.field, tab, this.config, this.userRoles);
|
|
980
|
+
// Re-use existing component if same type, otherwise recreate
|
|
981
|
+
if (this.mountedComponent && this.mountedComponent instanceof ComponentClass) {
|
|
982
|
+
this.setInputs(this.mountedComponent, masked);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
this.fieldHost.clear();
|
|
986
|
+
const ref = this.fieldHost.createComponent(ComponentClass);
|
|
987
|
+
this.mountedComponent = ref.instance;
|
|
988
|
+
this.setInputs(ref.instance, masked);
|
|
989
|
+
ref.changeDetectorRef.markForCheck();
|
|
990
|
+
}
|
|
991
|
+
/** Pass all 5 contract inputs via setInput() — uniform for all types (ADR-008) */
|
|
992
|
+
setInputs(instance, masked) {
|
|
993
|
+
if ('field' in instance)
|
|
994
|
+
instance.field = this.field;
|
|
995
|
+
if ('control' in instance)
|
|
996
|
+
instance.control = this.control;
|
|
997
|
+
if ('language' in instance)
|
|
998
|
+
instance.language = this.language;
|
|
999
|
+
if ('readonly' in instance)
|
|
1000
|
+
instance.readonly = this.readonly || !!this.field.readonly;
|
|
1001
|
+
if ('masked' in instance)
|
|
1002
|
+
instance.masked = masked;
|
|
1003
|
+
}
|
|
1004
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1005
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true });
|
|
1006
|
+
}
|
|
1007
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, decorators: [{
|
|
1008
|
+
type: Component,
|
|
1009
|
+
args: [{
|
|
1010
|
+
selector: 'ngx-dynamic-field',
|
|
1011
|
+
standalone: true,
|
|
1012
|
+
template: `<ng-container #fieldHost></ng-container>`,
|
|
1013
|
+
}]
|
|
1014
|
+
}], propDecorators: { field: [{
|
|
1015
|
+
type: Input
|
|
1016
|
+
}], control: [{
|
|
1017
|
+
type: Input
|
|
1018
|
+
}], config: [{
|
|
1019
|
+
type: Input
|
|
1020
|
+
}], language: [{
|
|
1021
|
+
type: Input
|
|
1022
|
+
}], readonly: [{
|
|
1023
|
+
type: Input
|
|
1024
|
+
}], userRoles: [{
|
|
1025
|
+
type: Input
|
|
1026
|
+
}], fieldHost: [{
|
|
1027
|
+
type: ViewChild,
|
|
1028
|
+
args: ['fieldHost', { read: ViewContainerRef, static: true }]
|
|
1029
|
+
}] } });
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* DynamicFormComponent — the main form component.
|
|
1033
|
+
* Renders a reactive form from EntityConfig with tab support, dependencies,
|
|
1034
|
+
* migration banner, and RBAC-gated submission.
|
|
1035
|
+
*
|
|
1036
|
+
* ONEHERMES pattern: isSaving state lock prevents subscription cyclones (ADR-002).
|
|
1037
|
+
* ONEHERMES bug fix: visibility rules re-evaluated after initialData loads (form-fixes doc).
|
|
1038
|
+
* ONEHERMES bug fix: use @for + @if separately, not both on same element (form-fixes doc).
|
|
1039
|
+
*/
|
|
1040
|
+
class DynamicFormComponent {
|
|
1041
|
+
// ─── Inputs ───────────────────────────────────────────────────────────────
|
|
1042
|
+
config;
|
|
1043
|
+
initialData;
|
|
1044
|
+
userRoles = [];
|
|
1045
|
+
language = 'en';
|
|
1046
|
+
readonly = false;
|
|
1047
|
+
loading = false;
|
|
1048
|
+
error = null;
|
|
1049
|
+
// ─── Outputs ──────────────────────────────────────────────────────────────
|
|
1050
|
+
formSubmit = new EventEmitter();
|
|
1051
|
+
formChange = new EventEmitter();
|
|
1052
|
+
formReset = new EventEmitter();
|
|
1053
|
+
// ─── Services ─────────────────────────────────────────────────────────────
|
|
1054
|
+
fb = inject(FormBuilder);
|
|
1055
|
+
validatorRegistry = inject(ValidatorRegistryService);
|
|
1056
|
+
hookRegistry = inject(HookRegistryService);
|
|
1057
|
+
versionService = inject(VersionService);
|
|
1058
|
+
rbacService = inject(RbacService);
|
|
1059
|
+
// ─── Signals (local reactive state) ───────────────────────────────────────
|
|
1060
|
+
activeTab = signal('');
|
|
1061
|
+
isSaving = signal(false); // ADR-002: isSaving lock prevents API spam
|
|
1062
|
+
// ─── Form ─────────────────────────────────────────────────────────────────
|
|
1063
|
+
form;
|
|
1064
|
+
// ─── Computed ─────────────────────────────────────────────────────────────
|
|
1065
|
+
get tabs() {
|
|
1066
|
+
return (this.config?.tabs || []).sort((a, b) => a.order - b.order);
|
|
1067
|
+
}
|
|
1068
|
+
get fieldsForActiveTab() {
|
|
1069
|
+
const tabId = this.activeTab();
|
|
1070
|
+
return (this.config?.fields || []).filter(f => {
|
|
1071
|
+
if (!this.tabs.length)
|
|
1072
|
+
return true; // No tabs — show all
|
|
1073
|
+
return f.tab === tabId || (!f.tab && tabId === this.tabs[0]?.id);
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
get permissions() {
|
|
1077
|
+
return this.rbacService.getPermissions(this.config, this.userRoles);
|
|
1078
|
+
}
|
|
1079
|
+
get needsMigration() {
|
|
1080
|
+
return this.versionService.needsMigration(this.initialData || {}, this.config);
|
|
1081
|
+
}
|
|
1082
|
+
get canSubmit() {
|
|
1083
|
+
return (this.permissions.canEdit &&
|
|
1084
|
+
!this.readonly &&
|
|
1085
|
+
!this.isSaving() &&
|
|
1086
|
+
!this.versionService.shouldBlockSubmit(this.initialData || {}, this.config));
|
|
1087
|
+
}
|
|
1088
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
1089
|
+
ngOnChanges(changes) {
|
|
1090
|
+
if (changes['config'] && this.config) {
|
|
1091
|
+
this.buildForm();
|
|
1092
|
+
}
|
|
1093
|
+
// ONEHERMES fix: re-apply data after initialData loads (form-fixes doc)
|
|
1094
|
+
if (changes['initialData'] && this.form && this.initialData) {
|
|
1095
|
+
this.patchForm(this.initialData);
|
|
1096
|
+
}
|
|
1097
|
+
if (changes['config'] && this.tabs.length) {
|
|
1098
|
+
this.activeTab.set(this.tabs[0]?.id || '');
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
buildForm() {
|
|
1102
|
+
const controls = {};
|
|
1103
|
+
for (const field of this.config.fields || []) {
|
|
1104
|
+
if (field.isSystem)
|
|
1105
|
+
continue; // System fields not in form controls
|
|
1106
|
+
const validators = this.validatorRegistry.resolveAll(field.validators);
|
|
1107
|
+
controls[field.id] = [field.defaultValue ?? null, validators];
|
|
1108
|
+
}
|
|
1109
|
+
this.form = this.fb.group(controls);
|
|
1110
|
+
// Emit changes via formChange output
|
|
1111
|
+
this.form.valueChanges.subscribe(value => this.formChange.emit(value));
|
|
1112
|
+
// Apply initial data after form builds
|
|
1113
|
+
if (this.initialData) {
|
|
1114
|
+
this.patchForm(this.initialData);
|
|
1115
|
+
}
|
|
1116
|
+
if (this.tabs.length && !this.activeTab()) {
|
|
1117
|
+
this.activeTab.set(this.tabs[0].id);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
patchForm(data) {
|
|
1121
|
+
// Filter empty objects (ONEHERMES array field empty row bug fix)
|
|
1122
|
+
const cleanData = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));
|
|
1123
|
+
this.form.patchValue(cleanData, { emitEvent: false });
|
|
1124
|
+
}
|
|
1125
|
+
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
1126
|
+
setActiveTab(tabId) {
|
|
1127
|
+
this.activeTab.set(tabId);
|
|
1128
|
+
}
|
|
1129
|
+
async submit() {
|
|
1130
|
+
if (!this.canSubmit || this.form.invalid) {
|
|
1131
|
+
this.form.markAllAsTouched();
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
// ADR-002: isSaving lock — prevents subscription cyclones / duplicate API calls
|
|
1135
|
+
if (this.isSaving())
|
|
1136
|
+
return;
|
|
1137
|
+
this.isSaving.set(true);
|
|
1138
|
+
try {
|
|
1139
|
+
let data = { ...this.form.value };
|
|
1140
|
+
// Run pre-submit hook (Angular-side)
|
|
1141
|
+
data = await this.hookRegistry.run(`${this.config.entity}:beforeSave`, data, {
|
|
1142
|
+
config: this.config,
|
|
1143
|
+
userRoles: this.userRoles,
|
|
1144
|
+
});
|
|
1145
|
+
this.formSubmit.emit(data);
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
// Reset saving lock in the consumer's success/error handler — here we just unblock
|
|
1149
|
+
this.isSaving.set(false);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
reset() {
|
|
1153
|
+
this.form.reset();
|
|
1154
|
+
if (this.initialData)
|
|
1155
|
+
this.patchForm(this.initialData);
|
|
1156
|
+
this.formReset.emit();
|
|
1157
|
+
}
|
|
1158
|
+
getControl(fieldId) {
|
|
1159
|
+
return this.form?.get(fieldId);
|
|
1160
|
+
}
|
|
1161
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1162
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset" }, usesOnChanges: true, ngImport: i0, template: "<form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Migration banner \u2014 visible in graceful mode when record is stale -->\n @if (needsMigration) {\n <div class=\"ngx-form__migration-banner\" role=\"alert\">\n \u26A0\uFE0F This record was created with an older configuration version.\n @if (versionService.shouldBlockSubmit(initialData || {}, config)) {\n Please run migration before editing.\n } @else {\n It will be updated to the latest version on save.\n }\n </div>\n }\n\n <!-- Error message -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Tab bar \u2014 only if tabs defined -->\n @if (tabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\">\n <!-- ONEHERMES fix: @for and @if on separate elements, not same (form-fixes doc) -->\n @for (tab of tabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ tab.label[language] || tab.label['en'] }}\n </button>\n }\n </div>\n }\n\n <!-- Fields for active tab -->\n <div class=\"ngx-form__panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n @if (field.visible !== false) {\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"readonly || !!field.readonly\"\n [userRoles]=\"userRoles\"\n />\n }\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n [disabled]=\"!canSubmit || form.invalid\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n</form>\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "language", "readonly", "userRoles"] }] });
|
|
1163
|
+
}
|
|
1164
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, decorators: [{
|
|
1165
|
+
type: Component,
|
|
1166
|
+
args: [{ selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent], template: "<form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Migration banner \u2014 visible in graceful mode when record is stale -->\n @if (needsMigration) {\n <div class=\"ngx-form__migration-banner\" role=\"alert\">\n \u26A0\uFE0F This record was created with an older configuration version.\n @if (versionService.shouldBlockSubmit(initialData || {}, config)) {\n Please run migration before editing.\n } @else {\n It will be updated to the latest version on save.\n }\n </div>\n }\n\n <!-- Error message -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Tab bar \u2014 only if tabs defined -->\n @if (tabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\">\n <!-- ONEHERMES fix: @for and @if on separate elements, not same (form-fixes doc) -->\n @for (tab of tabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ tab.label[language] || tab.label['en'] }}\n </button>\n }\n </div>\n }\n\n <!-- Fields for active tab -->\n <div class=\"ngx-form__panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n @if (field.visible !== false) {\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"readonly || !!field.readonly\"\n [userRoles]=\"userRoles\"\n />\n }\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n [disabled]=\"!canSubmit || form.invalid\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n</form>\n" }]
|
|
1167
|
+
}], propDecorators: { config: [{
|
|
1168
|
+
type: Input
|
|
1169
|
+
}], initialData: [{
|
|
1170
|
+
type: Input
|
|
1171
|
+
}], userRoles: [{
|
|
1172
|
+
type: Input
|
|
1173
|
+
}], language: [{
|
|
1174
|
+
type: Input
|
|
1175
|
+
}], readonly: [{
|
|
1176
|
+
type: Input
|
|
1177
|
+
}], loading: [{
|
|
1178
|
+
type: Input
|
|
1179
|
+
}], error: [{
|
|
1180
|
+
type: Input
|
|
1181
|
+
}], formSubmit: [{
|
|
1182
|
+
type: Output
|
|
1183
|
+
}], formChange: [{
|
|
1184
|
+
type: Output
|
|
1185
|
+
}], formReset: [{
|
|
1186
|
+
type: Output
|
|
1187
|
+
}] } });
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* DynamicTableComponent — renders entity records as a searchable, sortable, paginated table.
|
|
1191
|
+
* Columns determined by FieldConfig.tableColumn === true.
|
|
1192
|
+
* Cell masking applied via RbacService (defense-in-depth).
|
|
1193
|
+
* Pagination, sort, and search emit events — parent controls data fetching (smart/dumb pattern).
|
|
1194
|
+
*/
|
|
1195
|
+
class DynamicTableComponent {
|
|
1196
|
+
// ─── Inputs ───────────────────────────────────────────────────────────────
|
|
1197
|
+
config;
|
|
1198
|
+
data = [];
|
|
1199
|
+
userRoles = [];
|
|
1200
|
+
language = 'en';
|
|
1201
|
+
loading = false;
|
|
1202
|
+
error = null;
|
|
1203
|
+
totalRecords = 0;
|
|
1204
|
+
pageSize = 20;
|
|
1205
|
+
currentPage = 1;
|
|
1206
|
+
// ─── Outputs ──────────────────────────────────────────────────────────────
|
|
1207
|
+
rowClick = new EventEmitter();
|
|
1208
|
+
rowEdit = new EventEmitter();
|
|
1209
|
+
rowDelete = new EventEmitter();
|
|
1210
|
+
pageChange = new EventEmitter();
|
|
1211
|
+
pageSizeChange = new EventEmitter();
|
|
1212
|
+
sortChange = new EventEmitter();
|
|
1213
|
+
searchChange = new EventEmitter();
|
|
1214
|
+
// ─── Services ─────────────────────────────────────────────────────────────
|
|
1215
|
+
rbacService = inject(RbacService);
|
|
1216
|
+
versionService = inject(VersionService);
|
|
1217
|
+
// ─── Signals ──────────────────────────────────────────────────────────────
|
|
1218
|
+
sortField = signal('');
|
|
1219
|
+
sortDir = signal('desc');
|
|
1220
|
+
searchTerm = signal('');
|
|
1221
|
+
// ─── Computed ─────────────────────────────────────────────────────────────
|
|
1222
|
+
tableColumns = computed(() => (this.config?.fields || []).filter(f => f.tableColumn === true));
|
|
1223
|
+
get permissions() {
|
|
1224
|
+
return this.rbacService.getPermissions(this.config, this.userRoles);
|
|
1225
|
+
}
|
|
1226
|
+
get totalPages() {
|
|
1227
|
+
return Math.ceil(this.totalRecords / this.pageSize) || 0;
|
|
1228
|
+
}
|
|
1229
|
+
ngOnChanges(_changes) {
|
|
1230
|
+
// Pre-resolve masking per column once (ONEHERMES hardening: pre-resolved at extract time)
|
|
1231
|
+
this._resolveColumnMasks();
|
|
1232
|
+
}
|
|
1233
|
+
columnMaskCache = new Map();
|
|
1234
|
+
_resolveColumnMasks() {
|
|
1235
|
+
if (!this.config)
|
|
1236
|
+
return;
|
|
1237
|
+
this.columnMaskCache.clear();
|
|
1238
|
+
for (const field of this.tableColumns()) {
|
|
1239
|
+
const tab = (this.config.tabs || []).find(t => t.id === field.tab);
|
|
1240
|
+
const masked = this.rbacService.shouldMaskField(field, tab, this.config, this.userRoles);
|
|
1241
|
+
this.columnMaskCache.set(field.id, masked);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
1245
|
+
isCellMasked(fieldId) {
|
|
1246
|
+
return this.columnMaskCache.get(fieldId) ?? false;
|
|
1247
|
+
}
|
|
1248
|
+
getCellValue(record, fieldId) {
|
|
1249
|
+
if (this.isCellMasked(fieldId))
|
|
1250
|
+
return 'XXXXXXXXX';
|
|
1251
|
+
const value = record[fieldId];
|
|
1252
|
+
if (value === null || value === undefined)
|
|
1253
|
+
return '—';
|
|
1254
|
+
if (typeof value === 'boolean')
|
|
1255
|
+
return value ? 'Yes' : 'No';
|
|
1256
|
+
if (value instanceof Date || (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/))) {
|
|
1257
|
+
try {
|
|
1258
|
+
return new Date(value).toLocaleDateString();
|
|
1259
|
+
}
|
|
1260
|
+
catch {
|
|
1261
|
+
return String(value);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (Array.isArray(value))
|
|
1265
|
+
return value.join(', ');
|
|
1266
|
+
return String(value);
|
|
1267
|
+
}
|
|
1268
|
+
needsMigration(record) {
|
|
1269
|
+
return this.versionService.needsMigration(record, this.config);
|
|
1270
|
+
}
|
|
1271
|
+
getColumnLabel(fieldId) {
|
|
1272
|
+
const field = (this.config?.fields || []).find(f => f.id === fieldId);
|
|
1273
|
+
return field?.label?.[this.language] || field?.label?.['en'] || fieldId;
|
|
1274
|
+
}
|
|
1275
|
+
// ─── Sort / Search / Pagination ───────────────────────────────────────────
|
|
1276
|
+
onSort(fieldId) {
|
|
1277
|
+
if (this.sortField() === fieldId) {
|
|
1278
|
+
const next = this.sortDir() === 'asc' ? 'desc' : 'asc';
|
|
1279
|
+
this.sortDir.set(next);
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
this.sortField.set(fieldId);
|
|
1283
|
+
this.sortDir.set('asc');
|
|
1284
|
+
}
|
|
1285
|
+
this.sortChange.emit({ field: this.sortField(), dir: this.sortDir() });
|
|
1286
|
+
}
|
|
1287
|
+
onSearch(event) {
|
|
1288
|
+
const value = event.target.value;
|
|
1289
|
+
this.searchTerm.set(value);
|
|
1290
|
+
this.searchChange.emit(value);
|
|
1291
|
+
}
|
|
1292
|
+
onPageChange(page) {
|
|
1293
|
+
if (page < 1 || page > this.totalPages)
|
|
1294
|
+
return;
|
|
1295
|
+
this.pageChange.emit(page);
|
|
1296
|
+
}
|
|
1297
|
+
onEdit(event, record) {
|
|
1298
|
+
event.stopPropagation();
|
|
1299
|
+
this.rowEdit.emit(record);
|
|
1300
|
+
}
|
|
1301
|
+
onDelete(event, record) {
|
|
1302
|
+
event.stopPropagation();
|
|
1303
|
+
this.rowDelete.emit(record);
|
|
1304
|
+
}
|
|
1305
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicTableComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1306
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicTableComponent, isStandalone: true, selector: "ngx-dynamic-table", inputs: { config: "config", data: "data", userRoles: "userRoles", language: "language", loading: "loading", error: "error", totalRecords: "totalRecords", pageSize: "pageSize", currentPage: "currentPage" }, outputs: { rowClick: "rowClick", rowEdit: "rowEdit", rowDelete: "rowDelete", pageChange: "pageChange", pageSizeChange: "pageSizeChange", sortChange: "sortChange", searchChange: "searchChange" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-table\">\n\n <!-- Search bar -->\n <div class=\"ngx-table__toolbar\">\n <input\n class=\"ngx-table__search\"\n type=\"search\"\n placeholder=\"Search...\"\n [value]=\"searchTerm()\"\n (input)=\"onSearch($event)\"\n aria-label=\"Search records\"\n />\n </div>\n\n <!-- Error -->\n @if (error) {\n <div class=\"ngx-table__error\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Table -->\n <table class=\"ngx-table__table\">\n <thead>\n <tr>\n @for (col of tableColumns(); track col.id) {\n <th\n class=\"ngx-table__th\"\n [style.width]=\"col.columnWidth || 'auto'\"\n (click)=\"onSort(col.id)\"\n [attr.aria-sort]=\"sortField() === col.id ? sortDir() : 'none'\"\n role=\"columnheader\"\n >\n {{ getColumnLabel(col.id) }}\n @if (sortField() === col.id) {\n <span class=\"ngx-table__sort-icon\">{{ sortDir() === 'asc' ? '\u2191' : '\u2193' }}</span>\n }\n </th>\n }\n <!-- Actions column -->\n @if (permissions.canEdit || permissions.canDelete) {\n <th class=\"ngx-table__th ngx-table__th--actions\">Actions</th>\n }\n </tr>\n </thead>\n <tbody>\n @if (loading) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__loading\">Loading\u2026</td>\n </tr>\n } @else if (!data || !data.length) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__empty\">No records found</td>\n </tr>\n } @else {\n @for (record of data; track record['_id']) {\n <tr class=\"ngx-table__row\" (click)=\"rowClick.emit(record)\">\n @for (col of tableColumns(); track col.id) {\n <td\n class=\"ngx-table__td\"\n [class.ngx-table__td--masked]=\"isCellMasked(col.id)\"\n >\n {{ getCellValue(record, col.id) }}\n <!-- Migration badge \u2014 shows when record is stale -->\n @if (tableColumns().length > 0 && col.id === tableColumns()[0].id && needsMigration(record)) {\n <span class=\"ngx-table__migration-badge\" title=\"Needs migration\">\u26A0</span>\n }\n </td>\n }\n <!-- Action buttons \u2014 gated by RBAC permissions -->\n @if (permissions.canEdit || permissions.canDelete) {\n <td class=\"ngx-table__td ngx-table__actions\">\n @if (permissions.canEdit) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--edit\"\n (click)=\"onEdit($event, record)\"\n title=\"Edit\"\n >\u270F</button>\n }\n @if (permissions.canDelete) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--delete\"\n (click)=\"onDelete($event, record)\"\n title=\"Delete\"\n >\uD83D\uDDD1</button>\n }\n </td>\n }\n </tr>\n }\n }\n </tbody>\n </table>\n\n <!-- Pagination -->\n @if (totalPages > 1) {\n <div class=\"ngx-table__pagination\">\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage <= 1\"\n (click)=\"onPageChange(currentPage - 1)\"\n aria-label=\"Previous page\"\n >\u2190</button>\n\n <span class=\"ngx-table__page-info\">\n Page {{ currentPage }} of {{ totalPages }} ({{ totalRecords }} records)\n </span>\n\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage >= totalPages\"\n (click)=\"onPageChange(currentPage + 1)\"\n aria-label=\"Next page\"\n >\u2192</button>\n </div>\n }\n\n</div>\n" });
|
|
1307
|
+
}
|
|
1308
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicTableComponent, decorators: [{
|
|
1309
|
+
type: Component,
|
|
1310
|
+
args: [{ selector: 'ngx-dynamic-table', standalone: true, imports: [], template: "<div class=\"ngx-table\">\n\n <!-- Search bar -->\n <div class=\"ngx-table__toolbar\">\n <input\n class=\"ngx-table__search\"\n type=\"search\"\n placeholder=\"Search...\"\n [value]=\"searchTerm()\"\n (input)=\"onSearch($event)\"\n aria-label=\"Search records\"\n />\n </div>\n\n <!-- Error -->\n @if (error) {\n <div class=\"ngx-table__error\" role=\"alert\">{{ error }}</div>\n }\n\n <!-- Table -->\n <table class=\"ngx-table__table\">\n <thead>\n <tr>\n @for (col of tableColumns(); track col.id) {\n <th\n class=\"ngx-table__th\"\n [style.width]=\"col.columnWidth || 'auto'\"\n (click)=\"onSort(col.id)\"\n [attr.aria-sort]=\"sortField() === col.id ? sortDir() : 'none'\"\n role=\"columnheader\"\n >\n {{ getColumnLabel(col.id) }}\n @if (sortField() === col.id) {\n <span class=\"ngx-table__sort-icon\">{{ sortDir() === 'asc' ? '\u2191' : '\u2193' }}</span>\n }\n </th>\n }\n <!-- Actions column -->\n @if (permissions.canEdit || permissions.canDelete) {\n <th class=\"ngx-table__th ngx-table__th--actions\">Actions</th>\n }\n </tr>\n </thead>\n <tbody>\n @if (loading) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__loading\">Loading\u2026</td>\n </tr>\n } @else if (!data || !data.length) {\n <tr>\n <td [attr.colspan]=\"tableColumns().length + 1\" class=\"ngx-table__empty\">No records found</td>\n </tr>\n } @else {\n @for (record of data; track record['_id']) {\n <tr class=\"ngx-table__row\" (click)=\"rowClick.emit(record)\">\n @for (col of tableColumns(); track col.id) {\n <td\n class=\"ngx-table__td\"\n [class.ngx-table__td--masked]=\"isCellMasked(col.id)\"\n >\n {{ getCellValue(record, col.id) }}\n <!-- Migration badge \u2014 shows when record is stale -->\n @if (tableColumns().length > 0 && col.id === tableColumns()[0].id && needsMigration(record)) {\n <span class=\"ngx-table__migration-badge\" title=\"Needs migration\">\u26A0</span>\n }\n </td>\n }\n <!-- Action buttons \u2014 gated by RBAC permissions -->\n @if (permissions.canEdit || permissions.canDelete) {\n <td class=\"ngx-table__td ngx-table__actions\">\n @if (permissions.canEdit) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--edit\"\n (click)=\"onEdit($event, record)\"\n title=\"Edit\"\n >\u270F</button>\n }\n @if (permissions.canDelete) {\n <button\n type=\"button\"\n class=\"ngx-table__action ngx-table__action--delete\"\n (click)=\"onDelete($event, record)\"\n title=\"Delete\"\n >\uD83D\uDDD1</button>\n }\n </td>\n }\n </tr>\n }\n }\n </tbody>\n </table>\n\n <!-- Pagination -->\n @if (totalPages > 1) {\n <div class=\"ngx-table__pagination\">\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage <= 1\"\n (click)=\"onPageChange(currentPage - 1)\"\n aria-label=\"Previous page\"\n >\u2190</button>\n\n <span class=\"ngx-table__page-info\">\n Page {{ currentPage }} of {{ totalPages }} ({{ totalRecords }} records)\n </span>\n\n <button\n type=\"button\"\n class=\"ngx-table__page-btn\"\n [disabled]=\"currentPage >= totalPages\"\n (click)=\"onPageChange(currentPage + 1)\"\n aria-label=\"Next page\"\n >\u2192</button>\n </div>\n }\n\n</div>\n" }]
|
|
1311
|
+
}], propDecorators: { config: [{
|
|
1312
|
+
type: Input
|
|
1313
|
+
}], data: [{
|
|
1314
|
+
type: Input
|
|
1315
|
+
}], userRoles: [{
|
|
1316
|
+
type: Input
|
|
1317
|
+
}], language: [{
|
|
1318
|
+
type: Input
|
|
1319
|
+
}], loading: [{
|
|
1320
|
+
type: Input
|
|
1321
|
+
}], error: [{
|
|
1322
|
+
type: Input
|
|
1323
|
+
}], totalRecords: [{
|
|
1324
|
+
type: Input
|
|
1325
|
+
}], pageSize: [{
|
|
1326
|
+
type: Input
|
|
1327
|
+
}], currentPage: [{
|
|
1328
|
+
type: Input
|
|
1329
|
+
}], rowClick: [{
|
|
1330
|
+
type: Output
|
|
1331
|
+
}], rowEdit: [{
|
|
1332
|
+
type: Output
|
|
1333
|
+
}], rowDelete: [{
|
|
1334
|
+
type: Output
|
|
1335
|
+
}], pageChange: [{
|
|
1336
|
+
type: Output
|
|
1337
|
+
}], pageSizeChange: [{
|
|
1338
|
+
type: Output
|
|
1339
|
+
}], sortChange: [{
|
|
1340
|
+
type: Output
|
|
1341
|
+
}], searchChange: [{
|
|
1342
|
+
type: Output
|
|
1343
|
+
}] } });
|
|
1344
|
+
|
|
1345
|
+
// Tokens
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Generated bundle index. Do not edit.
|
|
1349
|
+
*/
|
|
1350
|
+
|
|
1351
|
+
export { CheckboxFieldComponent, ConfigService, DYNAMIC_ENTITY_API_URL, DateFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicTableComponent, ENTITY_REF_REGISTRY, EntityRefFieldComponent, EntityRefRegistryService, FIELD_TYPE_REGISTRY, FieldRegistryService, HOOK_REGISTRY, HookRegistryService, MASKED_ROLES, MIGRATION_STRATEGY, MultiSelectFieldComponent, NumberFieldComponent, RbacService, TextFieldComponent, TextareaFieldComponent, VALIDATOR_REGISTRY, ValidatorRegistryService, VersionService, provideNgxDynamicEntity };
|
|
1352
|
+
//# sourceMappingURL=ngx-dynamic-entity.mjs.map
|