@yuuvis/client-framework 3.22.1 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,15 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, DestroyRef, input, signal, effect, untracked, Component, forwardRef, ChangeDetectionStrategy, ChangeDetectorRef, ElementRef, computed, linkedSignal, viewChild, output, Input, NgModule } from '@angular/core';
2
+ import { inject, DestroyRef, input, signal, effect, untracked, Component, ChangeDetectorRef, forwardRef, ChangeDetectionStrategy, ElementRef, computed, linkedSignal, viewChild, output, Input, NgModule } from '@angular/core';
3
3
  import { takeUntilDestroyed, rxResource } from '@angular/core/rxjs-interop';
4
4
  import * as i1$1 from '@angular/forms';
5
5
  import { FormControl, Validators, ReactiveFormsModule, FormBuilder, NG_VALUE_ACCESSOR, NG_VALIDATORS, FormGroup, FormsModule } from '@angular/forms';
6
6
  import { MatFormFieldControl, MatFormFieldModule } from '@angular/material/form-field';
7
7
  import * as i1 from '@angular/material/select';
8
8
  import { MatSelectModule } from '@angular/material/select';
9
- import { SystemService, TranslateService, Classification, TranslatePipe, Situation, Utils, Operator, OperatorLabel, CatalogService, LocaleNumberPipe, FileSizePipe, IdmService, UserService, AppCacheService, FREE_TEXT_OPTION, USER_ID_REGEX, STORAGE_ROLES_KEY, SearchUtils, LocaleDatePipe, SearchService, DmsService, ObjectConfigService, DmsObject, BaseObjectTypeField, ClassificationPrefix } from '@yuuvis/client-core';
9
+ import { SystemService, TranslateService, Classification, TranslatePipe, Situation, ObjectFormControl, Utils, Operator, OperatorLabel, CatalogService, LocaleNumberPipe, FileSizePipe, IdmService, UserService, AppCacheService, FREE_TEXT_OPTION, USER_ID_REGEX, STORAGE_ROLES_KEY, SearchUtils, LocaleDatePipe, SearchService, DmsService, ObjectConfigService, DmsObject, BaseObjectTypeField, ClassificationPrefix } from '@yuuvis/client-core';
10
10
  import { AbstractMatFormField, injectNgControl, FormTranslateService, DialogComponent, ScrollButtonsComponent } from '@yuuvis/client-framework/common';
11
11
  import * as i1$3 from '@angular/common';
12
- import { NgClass, CommonModule } from '@angular/common';
12
+ import { CommonModule, NgClass } from '@angular/common';
13
13
  import { MatButtonModule } from '@angular/material/button';
14
14
  import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule, MatDialog } from '@angular/material/dialog';
15
15
  import * as i2 from '@angular/material/icon';
@@ -42,6 +42,15 @@ class FormUtils {
42
42
  static getErrorState(control) {
43
43
  return control.invalid && (control.dirty || control.touched);
44
44
  }
45
+ /**
46
+ * Writes the errors of a form element's inner control onto the hosting control without
47
+ * dropping the host's own validation result (e.g. `required` set up by the surrounding form).
48
+ * `host.setErrors(inner.errors)` alone would wipe those whenever the inner control is valid.
49
+ */
50
+ static mergeInnerErrors(host, inner) {
51
+ const ownErrors = host.validator ? host.validator(host) : null;
52
+ host.setErrors(ownErrors || inner.errors ? { ...ownErrors, ...inner.errors } : null);
53
+ }
45
54
  }
46
55
 
47
56
  class CatalogComponent extends AbstractMatFormField {
@@ -152,30 +161,254 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImpo
152
161
  args: [{ selector: 'yuv-catalog', imports: [MatSelectModule, ReactiveFormsModule, TranslatePipe], providers: [{ provide: MatFormFieldControl, useExisting: CatalogComponent }], template: "<mat-select [multiple]=\"multiple()\" [disabled]=\"readonly()\" [formControl]=\"formCtrl\">\n @if (!multiple() && !required) {\n <mat-option [value]=\"null\">{{ 'yuv.form.element.catalog.option.none' | translate }}</mat-option>\n }\n @for (o of _options(); track o) {\n <mat-option [value]=\"o.value\">{{ o.label }}</mat-option>\n }\n</mat-select>\n" }]
153
162
  }], propDecorators: { readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], classifications: [{ type: i0.Input, args: [{ isSignal: true, alias: "classifications", required: false }] }], situation: [{ type: i0.Input, args: [{ isSignal: true, alias: "situation", required: false }] }] } });
154
163
 
164
+ const HEX_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
165
+ /**
166
+ * Form input for a color value. Renders a hex text input alongside a native color picker
167
+ * swatch; both stay in sync. The value is a hex color string (e.g. `#2A70AC`).
168
+ *
169
+ * Implements `ControlValueAccessor` so it can be used within Angular forms and Material form
170
+ * fields.
171
+ *
172
+ * @example
173
+ * <mat-form-field>
174
+ * <mat-label>Primary color</mat-label>
175
+ * <yuv-color formControlName="primaryColor"></yuv-color>
176
+ * </mat-form-field>
177
+ */
178
+ class ColorComponent extends AbstractMatFormField {
179
+ constructor() {
180
+ super(...arguments);
181
+ this.#dRef = inject(DestroyRef);
182
+ /** the color swatch (native `<input type="color">`, always a full 6-digit hex) */
183
+ this.colorFc = new FormControl('#000000', { nonNullable: true });
184
+ /** the editable hex text input (source of truth for the value) */
185
+ this.textFc = new FormControl(null);
186
+ this.ngControl = injectNgControl(this);
187
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
188
+ this.propagateChange = (_) => { };
189
+ }
190
+ #dRef;
191
+ writeValue(value) {
192
+ this.value = value;
193
+ this.textFc.setValue(value ?? null, { emitEvent: false });
194
+ if (value && HEX_PATTERN.test(value)) {
195
+ this.colorFc.setValue(value, { emitEvent: false });
196
+ }
197
+ }
198
+ registerOnChange(fn) {
199
+ this.propagateChange = fn;
200
+ }
201
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
202
+ registerOnTouched() { }
203
+ setDisabledState(isDisabled) {
204
+ if (isDisabled) {
205
+ this.colorFc.disable();
206
+ this.textFc.disable();
207
+ }
208
+ else {
209
+ this.colorFc.enable();
210
+ this.textFc.enable();
211
+ }
212
+ this.disabled = isDisabled;
213
+ }
214
+ ngOnInit() {
215
+ const validators = [(control) => this.#hexValidator(control.value)];
216
+ if (this.required)
217
+ validators.push(() => (this.value ? null : { required: true }));
218
+ this.textFc.setValidators(validators);
219
+ this.textFc.statusChanges.pipe(takeUntilDestroyed(this.#dRef)).subscribe(() => {
220
+ this.errorState = FormUtils.getErrorState(this.textFc);
221
+ this.ngControl?.control?.setErrors(this.textFc.errors);
222
+ });
223
+ // hex text drives the value; keep the swatch in sync when the text is a valid hex
224
+ this.textFc.valueChanges.pipe(takeUntilDestroyed(this.#dRef)).subscribe((v) => {
225
+ const hex = v?.trim() || null;
226
+ this.value = hex;
227
+ if (hex && HEX_PATTERN.test(hex)) {
228
+ this.colorFc.setValue(hex, { emitEvent: false });
229
+ }
230
+ this.propagateChange(hex);
231
+ });
232
+ // swatch changes update the hex text (which re-propagates)
233
+ this.colorFc.valueChanges.pipe(takeUntilDestroyed(this.#dRef)).subscribe((v) => {
234
+ this.textFc.setValue(v);
235
+ });
236
+ this.textFc.updateValueAndValidity();
237
+ }
238
+ ngOnDestroy() {
239
+ super.onNgOnDestroy();
240
+ }
241
+ #hexValidator(value) {
242
+ if (!value)
243
+ return null;
244
+ return HEX_PATTERN.test(value.trim()) ? null : { color: { valid: false } };
245
+ }
246
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.20", ngImport: i0, type: ColorComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
247
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.20", type: ColorComponent, isStandalone: true, selector: "yuv-color", providers: [{ provide: MatFormFieldControl, useExisting: ColorComponent }], usesInheritance: true, ngImport: i0, template: "<span class=\"yuv-color\">\n <label class=\"yuv-color__swatch\" [style.background-color]=\"colorFc.value\">\n <input type=\"color\" [formControl]=\"colorFc\" aria-hidden=\"true\" tabindex=\"-1\" />\n </label>\n <input class=\"yuv-color__hex\" type=\"text\" autocomplete=\"off\" spellcheck=\"false\" [formControl]=\"textFc\" />\n</span>\n", styles: [".yuv-color{display:inline-flex;align-items:center;gap:var(--ymt-spacing-xs);width:100%;margin-block-start:var(--ymt-spacing-xs)}.yuv-color__swatch{position:relative;flex:0 0 auto;inline-size:var(--ymt-sizing-m);block-size:var(--ymt-sizing-m);border-radius:var(--ymt-corner-xs);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);overflow:hidden;cursor:pointer;outline:2px solid currentColor;outline-offset:-1px}.yuv-color__swatch input[type=color]{position:absolute;inset:0;inline-size:100%;block-size:100%;padding:0;border:none;opacity:0;cursor:pointer}.yuv-color__hex{flex:1 1 auto;min-inline-size:0;border:none;outline:none;background:transparent;color:var(--ymt-text-color);font:var(--ymt-font-body)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.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$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] }); }
248
+ }
249
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImport: i0, type: ColorComponent, decorators: [{
250
+ type: Component,
251
+ args: [{ selector: 'yuv-color', standalone: true, imports: [CommonModule, ReactiveFormsModule], providers: [{ provide: MatFormFieldControl, useExisting: ColorComponent }], template: "<span class=\"yuv-color\">\n <label class=\"yuv-color__swatch\" [style.background-color]=\"colorFc.value\">\n <input type=\"color\" [formControl]=\"colorFc\" aria-hidden=\"true\" tabindex=\"-1\" />\n </label>\n <input class=\"yuv-color__hex\" type=\"text\" autocomplete=\"off\" spellcheck=\"false\" [formControl]=\"textFc\" />\n</span>\n", styles: [".yuv-color{display:inline-flex;align-items:center;gap:var(--ymt-spacing-xs);width:100%;margin-block-start:var(--ymt-spacing-xs)}.yuv-color__swatch{position:relative;flex:0 0 auto;inline-size:var(--ymt-sizing-m);block-size:var(--ymt-sizing-m);border-radius:var(--ymt-corner-xs);border:var(--ymt-outline-width) solid var(--ymt-outline-variant);overflow:hidden;cursor:pointer;outline:2px solid currentColor;outline-offset:-1px}.yuv-color__swatch input[type=color]{position:absolute;inset:0;inline-size:100%;block-size:100%;padding:0;border:none;opacity:0;cursor:pointer}.yuv-color__hex{flex:1 1 auto;min-inline-size:0;border:none;outline:none;background:transparent;color:var(--ymt-text-color);font:var(--ymt-font-body)}\n"] }]
252
+ }] });
253
+
254
+ /**
255
+ * @ignore
256
+ * Scripting element for one cell of the table row that is being edited. Mirrors the
257
+ * properties form scripts know from the elements of `scope.model`, but applies changes
258
+ * to the controls of the row editing dialog instead of the object form.
259
+ */
260
+ class TableRowScopeElement {
261
+ constructor(element, onChange) {
262
+ this.element = element;
263
+ this.onChange = onChange;
264
+ if (this.element['value'] === undefined) {
265
+ this.element['value'] = null;
266
+ }
267
+ }
268
+ /**
269
+ * Called when the user changed the cell value. Triggers the elements `onchange` callback
270
+ * with the element itself and the rows model (siblings), like form scripts get it for table rows.
271
+ */
272
+ update(value, rowModel) {
273
+ const newValue = value === undefined || value === '' ? null : value;
274
+ if (JSON.stringify(this.element['value']) === JSON.stringify(newValue))
275
+ return;
276
+ this.element['value'] = newValue;
277
+ const onchange = this.onchange;
278
+ if (typeof onchange === 'function') {
279
+ try {
280
+ onchange(this, rowModel);
281
+ }
282
+ catch (err) {
283
+ console.error(`Error in form script onchange of table column '${this.name}'`, err);
284
+ }
285
+ }
286
+ }
287
+ set value(value) {
288
+ if (JSON.stringify(this.element['value']) === JSON.stringify(value))
289
+ return;
290
+ this.element['value'] = value;
291
+ this.onChange(this.name, { name: 'value', newValue: value });
292
+ }
293
+ get value() {
294
+ return this.element['value'];
295
+ }
296
+ set required(required) {
297
+ this.element['required'] = required;
298
+ this.onChange(this.name, { name: 'required', newValue: required });
299
+ }
300
+ get required() {
301
+ return !!this.element['required'];
302
+ }
303
+ set readonly(readonly) {
304
+ this.element['readonly'] = readonly;
305
+ this.onChange(this.name, { name: 'readonly', newValue: readonly });
306
+ }
307
+ get readonly() {
308
+ return !!this.element['readonly'];
309
+ }
310
+ set error(error) {
311
+ this.element['error'] = error;
312
+ this.onChange(this.name, { name: 'error', newValue: error });
313
+ }
314
+ get error() {
315
+ return this.element['error'] || null;
316
+ }
317
+ set onchange(onchange) {
318
+ this.element['onchange'] = onchange;
319
+ }
320
+ get onchange() {
321
+ return this.element['onchange'] || null;
322
+ }
323
+ get name() {
324
+ return this.element.name;
325
+ }
326
+ get label() {
327
+ return this.element['label'];
328
+ }
329
+ get description() {
330
+ return this.element['description'];
331
+ }
332
+ get type() {
333
+ return this.element['type'];
334
+ }
335
+ get classifications() {
336
+ return this.element['classifications'];
337
+ }
338
+ get scale() {
339
+ return this.element['scale'];
340
+ }
341
+ get precision() {
342
+ return this.element['precision'];
343
+ }
344
+ }
345
+
155
346
  class EditTableDataComponent {
156
347
  constructor() {
157
348
  this.#fb = inject(FormBuilder);
158
349
  this.#system = inject(SystemService);
159
350
  this.#errorMessage = inject(FormTranslateService);
351
+ this.#destroyRef = inject(DestroyRef);
352
+ this.#cdRef = inject(ChangeDetectorRef);
160
353
  this.#dialogData = inject(MAT_DIALOG_DATA);
161
354
  this.#dialogRef = inject((MatDialogRef));
162
355
  this.errorMessage = signal(null, ...(ngDevMode ? [{ debugName: "errorMessage" }] : /* istanbul ignore next */ []));
163
356
  this.tableForm = this.#fb.group({});
164
357
  this.isInnerTableForm = input(true, ...(ngDevMode ? [{ debugName: "isInnerTableForm" }] : /* istanbul ignore next */ []));
165
358
  this.formElement = this.#dialogData.formElement || {};
166
- this.columns = (this.formElement['elements'] || []).map((c) => ({
359
+ this.columns = signal((this.formElement['elements'] || []).map((c) => ({
167
360
  ...c,
168
361
  propertyType: c.type,
169
362
  _internalType: this.#system.getInternalFormElementType(c.type, c.classifications, c.catalog)
170
- }));
363
+ })), ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
171
364
  this.#elementData = this.#dialogData.elementData || {};
172
365
  this.situation = this.#dialogData.situation || Situation.EDIT;
173
366
  this.header = this.#dialogData.label || '';
174
367
  this.adding = this.#dialogData.adding;
368
+ // may be disabled by the tables 'onrowedit' form script callback
369
+ this.saveEnabled = signal(true, ...(ngDevMode ? [{ debugName: "saveEnabled" }] : /* istanbul ignore next */ []));
370
+ this.#onRowScriptingChange = (name, change) => {
371
+ const control = this.tableForm.get(name);
372
+ if (!control)
373
+ return;
374
+ switch (change.name) {
375
+ case 'value': {
376
+ control.setValue(change.newValue);
377
+ control.markAsDirty();
378
+ break;
379
+ }
380
+ case 'required': {
381
+ this.#updateColumn(control, { required: !!change.newValue });
382
+ control.setValidators(this.#getValidators(control._eoFormElement));
383
+ control.markAsTouched();
384
+ control.updateValueAndValidity();
385
+ break;
386
+ }
387
+ case 'readonly': {
388
+ this.#updateColumn(control, { readonly: !!change.newValue });
389
+ if (change.newValue) {
390
+ control.disable();
391
+ }
392
+ else {
393
+ control.enable();
394
+ }
395
+ break;
396
+ }
397
+ case 'error': {
398
+ control._eoFormElement.error = change.newValue;
399
+ control.markAsTouched();
400
+ control.updateValueAndValidity();
401
+ break;
402
+ }
403
+ }
404
+ this.#cdRef.markForCheck();
405
+ };
175
406
  }
176
407
  #fb;
177
408
  #system;
178
409
  #errorMessage;
410
+ #destroyRef;
411
+ #cdRef;
179
412
  #dialogData;
180
413
  #dialogRef;
181
414
  #elementData;
@@ -185,16 +418,32 @@ class EditTableDataComponent {
185
418
  submit() {
186
419
  const data = this.tableForm.getRawValue();
187
420
  Object.keys(data).forEach((key) => (data[key] === '' || data[key] === undefined) && (data[key] = null));
188
- if (this.tableForm.valid && this.#dialogRef)
421
+ if (this.tableForm.valid && this.saveEnabled() && this.#dialogRef)
189
422
  this.#dialogRef.close(data);
190
423
  }
191
424
  ngOnInit() {
192
- Object.values(this.formElement['elements']).forEach((element) => {
193
- return this.tableForm.addControl(element.name, this.#fb.control({ value: '', disabled: element.readonly }, element.required ? Validators.required : null));
425
+ this.#initFormControls();
426
+ this.#initErrorMessages();
427
+ this.#initRowScripting();
428
+ }
429
+ /**
430
+ * Adds one control per table column to the row form and fills it with the data of the row being edited.
431
+ */
432
+ #initFormControls() {
433
+ this.columns().forEach((column) => {
434
+ const control = new ObjectFormControl({ value: '', disabled: column.readonly });
435
+ control._eoFormElement = column;
436
+ control.setValidators(this.#getValidators(column));
437
+ this.tableForm.addControl(column.name, control);
194
438
  });
195
439
  this.tableForm.setValidators(this.#globalErrorValidator());
196
440
  this.#elementData && this.tableForm.patchValue(this.#elementData);
197
- this.tableForm.valueChanges.subscribe((v) => {
441
+ }
442
+ /**
443
+ * Keeps the error messages shown by the dialog in sync with the errors of the row form.
444
+ */
445
+ #initErrorMessages() {
446
+ this.tableForm.valueChanges.subscribe(() => {
198
447
  this.errorMessage.set(this.tableForm.errors
199
448
  ? Object.keys(this.tableForm.errors).map((key) => this.tableForm.errors && this.tableForm.errors[key]
200
449
  ? Object.keys(this.tableForm.errors[key]).map((k) => ({
@@ -204,6 +453,57 @@ class EditTableDataComponent {
204
453
  : null);
205
454
  });
206
455
  }
456
+ /**
457
+ * Runs the 'onrowedit' callback the table form element provides (see `TableRowEditSupport`),
458
+ * e.g. registered by a form script. The callback gets the table element and a 'row' object
459
+ * whose model provides access to the cells of the row being edited. Changes applied to the
460
+ * row model are reflected by the dialog immediately and are discarded once it is closed.
461
+ */
462
+ #initRowScripting() {
463
+ const rowEdit = this.#dialogData.rowEdit;
464
+ if (!rowEdit)
465
+ return;
466
+ const model = {};
467
+ this.columns().forEach((column) => {
468
+ const value = this.tableForm.get(column.name)?.value;
469
+ const element = { ...column, value: value === '' ? null : value };
470
+ model[column.name] = new TableRowScopeElement(element, this.#onRowScriptingChange);
471
+ });
472
+ const row = {
473
+ index: rowEdit.index,
474
+ persisted: rowEdit.persisted,
475
+ saveEnabled: true,
476
+ model
477
+ };
478
+ try {
479
+ rowEdit.callback(rowEdit.table, row);
480
+ }
481
+ catch (err) {
482
+ console.error(`Error in form script onrowedit of table '${this.formElement['name']}'`, err);
483
+ }
484
+ // row properties are only evaluated synchronously
485
+ this.saveEnabled.set(row.saveEnabled !== false);
486
+ // subscribe after running the callback, so values set by onrowedit won't trigger onchange handlers
487
+ Object.keys(model).forEach((name) => this.tableForm
488
+ .get(name)
489
+ ?.valueChanges.pipe(takeUntilDestroyed(this.#destroyRef))
490
+ .subscribe((value) => model[name].update(value, model)));
491
+ }
492
+ #onRowScriptingChange;
493
+ // replace the column so the form field picks up the changed properties
494
+ #updateColumn(control, patch) {
495
+ const column = { ...control._eoFormElement, ...patch };
496
+ control._eoFormElement = column;
497
+ this.columns.update((columns) => columns.map((col) => (col.name === column.name ? column : col)));
498
+ }
499
+ #getValidators(column) {
500
+ // errors set by form scripts (rendered by the form field using the 'eoformScript' key)
501
+ const scriptError = (control) => control._eoFormElement?.error ? { eoformScript: { valid: false } } : null;
502
+ const validators = [scriptError];
503
+ if (column.required)
504
+ validators.push(Validators.required);
505
+ return validators;
506
+ }
207
507
  #globalErrorValidator() {
208
508
  return (control) => {
209
509
  const formGroup = control;
@@ -234,7 +534,7 @@ class EditTableDataComponent {
234
534
  useExisting: forwardRef(() => EditTableDataComponent),
235
535
  multi: true
236
536
  }
237
- ], ngImport: i0, template: "<yuv-dialog [headertitle]=\"(adding ? 'yuv.form.element.data.grid.add.headline' : 'yuv.form.element.data.grid.edit.headline') | translate: { headline: header }\">\n <main>\n <div [formGroup]=\"tableForm\">\n <div class=\"fields\">\n @for (element of columns; track $index) {\n <div [attr.data-name]=\"element.name\" class=\"form-field\">\n <yuv-metadata-form-field [field]=\"element\" [formControlName]=\"element.name\" [situation]=\"situation\"></yuv-metadata-form-field>\n </div>\n }\n </div>\n </div>\n </main>\n\n <footer>\n <button ymtButton=\"secondary\" (click)=\"cancel()\">{{ 'yuv.form.element.data.grid.edit.cancel' | translate }}</button>\n <button ymtButton=\"primary\" (click)=\"submit()\" [disabled]=\"!tableForm.dirty || !tableForm.valid\">\n @if (adding) {\n {{ 'yuv.form.element.data.grid.edit.add' | translate }}\n } @else {\n {{ 'yuv.object-metadata.button.save' | translate }}\n }\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host .fields{padding:var(--ymt-spacing-m)}:host .fields .form-field:not(:last-child){margin-block-end:var(--ymt-spacing-s)}:host .err-msg{font:var(--ymt-font-body-subtle);color:var(--ymt-danger);padding:var(--ymt-spacing-2xs) 0;border:0}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "ngmodule", type: MatDialogModule }, { kind: "ngmodule", type: MatButtonModule }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: MetadataFormFieldComponent, selector: "yuv-metadata-form-field", inputs: ["formChangedSubject", "field", "variant", "situation"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
537
+ ], ngImport: i0, template: "<yuv-dialog [headertitle]=\"(adding ? 'yuv.form.element.data.grid.add.headline' : 'yuv.form.element.data.grid.edit.headline') | translate: { headline: header }\">\n <main>\n <div [formGroup]=\"tableForm\">\n <div class=\"fields\">\n @for (element of columns(); track element.name) {\n <div [attr.data-name]=\"element.name\" class=\"form-field\">\n <yuv-metadata-form-field [field]=\"element\" [formControlName]=\"element.name\" [situation]=\"situation\"></yuv-metadata-form-field>\n </div>\n }\n </div>\n </div>\n </main>\n\n <footer>\n <button ymtButton=\"secondary\" (click)=\"cancel()\">{{ 'yuv.form.element.data.grid.edit.cancel' | translate }}</button>\n <button ymtButton=\"primary\" (click)=\"submit()\" [disabled]=\"!tableForm.dirty || !tableForm.valid || !saveEnabled()\">\n @if (adding) {\n {{ 'yuv.form.element.data.grid.edit.add' | translate }}\n } @else {\n {{ 'yuv.object-metadata.button.save' | translate }}\n }\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host .fields{padding:var(--ymt-spacing-m)}:host .fields .form-field:not(:last-child){margin-block-end:var(--ymt-spacing-s)}:host .err-msg{font:var(--ymt-font-body-subtle);color:var(--ymt-danger);padding:var(--ymt-spacing-2xs) 0;border:0}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: DialogComponent, selector: "yuv-dialog", inputs: ["headertitle", "headertitel"] }, { kind: "ngmodule", type: MatDialogModule }, { kind: "ngmodule", type: MatButtonModule }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: MetadataFormFieldComponent, selector: "yuv-metadata-form-field", inputs: ["formChangedSubject", "field", "variant", "situation"] }, { kind: "directive", type: YmtButtonDirective, selector: "button[ymtButton], a[ymtButton]", inputs: ["ymtButton", "disabled", "aria-disabled", "disableRipple", "disabledInteractive", "button-size"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
238
538
  }
239
539
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImport: i0, type: EditTableDataComponent, decorators: [{
240
540
  type: Component,
@@ -253,7 +553,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImpo
253
553
  useExisting: forwardRef(() => EditTableDataComponent),
254
554
  multi: true
255
555
  }
256
- ], template: "<yuv-dialog [headertitle]=\"(adding ? 'yuv.form.element.data.grid.add.headline' : 'yuv.form.element.data.grid.edit.headline') | translate: { headline: header }\">\n <main>\n <div [formGroup]=\"tableForm\">\n <div class=\"fields\">\n @for (element of columns; track $index) {\n <div [attr.data-name]=\"element.name\" class=\"form-field\">\n <yuv-metadata-form-field [field]=\"element\" [formControlName]=\"element.name\" [situation]=\"situation\"></yuv-metadata-form-field>\n </div>\n }\n </div>\n </div>\n </main>\n\n <footer>\n <button ymtButton=\"secondary\" (click)=\"cancel()\">{{ 'yuv.form.element.data.grid.edit.cancel' | translate }}</button>\n <button ymtButton=\"primary\" (click)=\"submit()\" [disabled]=\"!tableForm.dirty || !tableForm.valid\">\n @if (adding) {\n {{ 'yuv.form.element.data.grid.edit.add' | translate }}\n } @else {\n {{ 'yuv.object-metadata.button.save' | translate }}\n }\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host .fields{padding:var(--ymt-spacing-m)}:host .fields .form-field:not(:last-child){margin-block-end:var(--ymt-spacing-s)}:host .err-msg{font:var(--ymt-font-body-subtle);color:var(--ymt-danger);padding:var(--ymt-spacing-2xs) 0;border:0}\n"] }]
556
+ ], template: "<yuv-dialog [headertitle]=\"(adding ? 'yuv.form.element.data.grid.add.headline' : 'yuv.form.element.data.grid.edit.headline') | translate: { headline: header }\">\n <main>\n <div [formGroup]=\"tableForm\">\n <div class=\"fields\">\n @for (element of columns(); track element.name) {\n <div [attr.data-name]=\"element.name\" class=\"form-field\">\n <yuv-metadata-form-field [field]=\"element\" [formControlName]=\"element.name\" [situation]=\"situation\"></yuv-metadata-form-field>\n </div>\n }\n </div>\n </div>\n </main>\n\n <footer>\n <button ymtButton=\"secondary\" (click)=\"cancel()\">{{ 'yuv.form.element.data.grid.edit.cancel' | translate }}</button>\n <button ymtButton=\"primary\" (click)=\"submit()\" [disabled]=\"!tableForm.dirty || !tableForm.valid || !saveEnabled()\">\n @if (adding) {\n {{ 'yuv.form.element.data.grid.edit.add' | translate }}\n } @else {\n {{ 'yuv.object-metadata.button.save' | translate }}\n }\n </button>\n </footer>\n</yuv-dialog>\n", styles: [":host .fields{padding:var(--ymt-spacing-m)}:host .fields .form-field:not(:last-child){margin-block-end:var(--ymt-spacing-s)}:host .err-msg{font:var(--ymt-font-body-subtle);color:var(--ymt-danger);padding:var(--ymt-spacing-2xs) 0;border:0}\n"] }]
257
557
  }], propDecorators: { isInnerTableForm: [{ type: i0.Input, args: [{ isSignal: true, alias: "isInnerTableForm", required: false }] }] } });
258
558
 
259
559
  var DataGridSizeType;
@@ -265,6 +565,7 @@ var DataGridSizeType;
265
565
  DataGridSizeType["AUTO"] = "auto";
266
566
  })(DataGridSizeType || (DataGridSizeType = {}));
267
567
 
568
+ const isRowObject = (row) => typeof row === 'object' && row !== null;
268
569
  const MATERIAL = [MatMenuModule, MatMenuTrigger, MatIconModule, MatButtonModule, MatTableModule, MatTooltipModule];
269
570
  class DataGridComponent {
270
571
  constructor() {
@@ -290,6 +591,12 @@ class DataGridComponent {
290
591
  : formElement && formElement['label'];
291
592
  }, ...(ngDevMode ? [{ debugName: "tableLabel" }] : /* istanbul ignore next */ []));
292
593
  this.selectedRow = signal(null, ...(ngDevMode ? [{ debugName: "selectedRow" }] : /* istanbul ignore next */ []));
594
+ // Rows that are persisted, i.e. not added while the current form is being edited ('row.persisted'
595
+ // of the 'onrowedit' callback). Rows are tracked by identity and carried over by value whenever
596
+ // they get recreated (e.g. by writeValue), so identical rows can't be told apart.
597
+ this.#persistedRows = new WeakSet();
598
+ // serialized rows the current form element has been loaded with
599
+ this.#loadedRows = [];
293
600
  this.isRequired = false;
294
601
  this.isInvalid = false;
295
602
  this.icons = {
@@ -314,7 +621,12 @@ class DataGridComponent {
314
621
  const data = this.#toRowObjects(formElement ? formElement['value'] || [] : [], elements);
315
622
  // rendering only — the value already lives in the form model, so a (re)bind
316
623
  // of the formElement input must never propagate back or dirty the control
317
- untracked(() => formElement && this.#updateTable(elements, data));
624
+ untracked(() => {
625
+ if (!formElement)
626
+ return;
627
+ this.#resetPersistedRows(data);
628
+ this.#updateTable(elements, data);
629
+ });
318
630
  }, ...(ngDevMode ? [{ debugName: "#loadData" }] : /* istanbul ignore next */ []));
319
631
  this.propagateChange = () => { };
320
632
  this.onTouched = () => { };
@@ -322,6 +634,12 @@ class DataGridComponent {
322
634
  #dialog;
323
635
  #systemService;
324
636
  #cdRef;
637
+ // Rows that are persisted, i.e. not added while the current form is being edited ('row.persisted'
638
+ // of the 'onrowedit' callback). Rows are tracked by identity and carried over by value whenever
639
+ // they get recreated (e.g. by writeValue), so identical rows can't be told apart.
640
+ #persistedRows;
641
+ // serialized rows the current form element has been loaded with
642
+ #loadedRows;
325
643
  #loadData;
326
644
  // if the form element is created from the ObjectTypeField it contains 'columnDefinitions' instead of 'elements'
327
645
  // therefore we need to map them here for proper table rendering
@@ -353,7 +671,7 @@ class DataGridComponent {
353
671
  return row;
354
672
  });
355
673
  }
356
- #openEditOverlay(elementData, adding = false) {
674
+ #openEditOverlay(elementData, adding = false, rowIndex = -1, persisted = false) {
357
675
  return this.#dialog
358
676
  .open(EditTableDataComponent, {
359
677
  minWidth: '25%',
@@ -364,7 +682,8 @@ class DataGridComponent {
364
682
  elementData,
365
683
  situation: this.situation(),
366
684
  adding,
367
- label: this.tableLabel()
685
+ label: this.tableLabel(),
686
+ rowEdit: this.#rowEditContext(rowIndex, persisted)
368
687
  }
369
688
  })
370
689
  .afterClosed()
@@ -377,8 +696,13 @@ class DataGridComponent {
377
696
  if (this.readonly())
378
697
  return;
379
698
  this.selectedRow.set(element);
380
- this.#openEditOverlay(element).subscribe((result) => {
699
+ const rowIndex = this.#rowIndex(element);
700
+ const persisted = isRowObject(element) && this.#persistedRows.has(element);
701
+ this.#openEditOverlay(element, false, rowIndex, persisted).subscribe((result) => {
381
702
  if (result) {
703
+ // an edited row keeps its persisted state
704
+ if (persisted)
705
+ this.#persistedRows.add(result);
382
706
  const updatedData = this.dataSource().map((item) => JSON.stringify(item) === JSON.stringify(element) ? result : item);
383
707
  const formElement = this.mappedFormElement();
384
708
  if (formElement) {
@@ -403,6 +727,43 @@ class DataGridComponent {
403
727
  }
404
728
  });
405
729
  }
730
+ // context for the row editing dialog, only if the table form element provides an 'onrowedit' callback
731
+ #rowEditContext(index, persisted) {
732
+ const formElement = this.mappedFormElement();
733
+ const callback = formElement?.onrowedit;
734
+ if (typeof callback !== 'function')
735
+ return undefined;
736
+ const resolve = formElement?.resolveRowEditTable;
737
+ const table = typeof resolve === 'function' ? resolve() : undefined;
738
+ return { callback, table: table ?? formElement, index, persisted };
739
+ }
740
+ // rows the form element is loaded with are persisted, unless the form is about to create a new object
741
+ #resetPersistedRows(rows) {
742
+ const persisted = this.situation() === Situation.CREATE ? [] : rows.filter(isRowObject);
743
+ this.#persistedRows = new WeakSet(persisted);
744
+ this.#loadedRows = persisted.map((row) => JSON.stringify(row));
745
+ }
746
+ // rows got recreated: carry over the persisted state of the previous (or the loaded) rows by value
747
+ #carryOverPersistedRows(previous, rows) {
748
+ const candidates = [
749
+ ...previous.filter((row) => isRowObject(row) && this.#persistedRows.has(row)).map((row) => JSON.stringify(row)),
750
+ ...this.#loadedRows
751
+ ];
752
+ rows.forEach((row) => {
753
+ if (!isRowObject(row) || this.#persistedRows.has(row))
754
+ return;
755
+ const idx = candidates.indexOf(JSON.stringify(row));
756
+ if (idx > -1) {
757
+ candidates.splice(idx, 1);
758
+ this.#persistedRows.add(row);
759
+ }
760
+ });
761
+ }
762
+ #rowIndex(element) {
763
+ const rows = this.dataSource();
764
+ const index = rows.indexOf(element);
765
+ return index > -1 ? index : rows.findIndex((item) => JSON.stringify(item) === JSON.stringify(element));
766
+ }
406
767
  removeRow(element) {
407
768
  const updatedData = this.dataSource().filter((item) => JSON.stringify(item) !== JSON.stringify(element));
408
769
  const formElement = this.mappedFormElement();
@@ -454,7 +815,9 @@ class DataGridComponent {
454
815
  */
455
816
  writeValue(obj) {
456
817
  const elements = this.#resolveElements(this.formElement());
457
- this.#updateTable(elements, this.#toRowObjects(Array.isArray(obj) ? obj : [], elements));
818
+ const rows = this.#toRowObjects(Array.isArray(obj) ? obj : [], elements);
819
+ this.#carryOverPersistedRows(this.dataSource(), rows);
820
+ this.#updateTable(elements, rows);
458
821
  }
459
822
  registerOnChange(fn) {
460
823
  this.propagateChange = fn;
@@ -535,6 +898,7 @@ class DatetimeComponent extends AbstractMatFormField {
535
898
  this.pickerCmp = viewChild.required(DatepickerComponent);
536
899
  this.ngControl = injectNgControl(this);
537
900
  this.fc = new FormControl(null);
901
+ this.#hostControl = null;
538
902
  this._locale = signal(this.translate.getCurrentLang(), ...(ngDevMode ? [{ debugName: "_locale" }] : /* istanbul ignore next */ []));
539
903
  this.locale = input(this.translate.getCurrentLang(), ...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
540
904
  this.#localeEffect = effect(() => {
@@ -560,6 +924,8 @@ class DatetimeComponent extends AbstractMatFormField {
560
924
  this.propagateChange = (_) => { };
561
925
  }
562
926
  #dRef;
927
+ #hostControl;
928
+ #hostControlSub;
563
929
  #localeEffect;
564
930
  openCalendar() {
565
931
  this.pickerCmp().openCalendar();
@@ -603,11 +969,29 @@ class DatetimeComponent extends AbstractMatFormField {
603
969
  this.translate.onLangChange.subscribe((evt) => this._locale.set(evt.lang));
604
970
  this.fc.valueChanges.pipe(takeUntilDestroyed(this.#dRef)).subscribe((value) => this.onValueChange(value));
605
971
  this.fc.statusChanges.pipe(takeUntilDestroyed(this.#dRef)).subscribe(() => {
606
- this.errorState = FormUtils.getErrorState(this.fc);
607
972
  if (this.ngControl?.control) {
608
- this.ngControl.control.setErrors(this.fc.errors);
973
+ FormUtils.mergeInnerErrors(this.ngControl.control, this.fc);
609
974
  }
975
+ this.#updateErrorState();
610
976
  });
977
+ this.#dRef.onDestroy(() => this.#hostControlSub?.unsubscribe());
978
+ }
979
+ ngDoCheck() {
980
+ // With `formControlName` the host control is only resolved after this component's `ngOnInit`,
981
+ // and a `[formControl]` binding may swap it later, so (re)attach whenever it changes.
982
+ const host = this.ngControl?.control ?? null;
983
+ if (host && host !== this.#hostControl) {
984
+ this.#hostControl = host;
985
+ this.#hostControlSub?.unsubscribe();
986
+ // The host carries the validation of the surrounding form (e.g. `required`) and may be
987
+ // touched from outside (form scripts, submit handling), so reflect it in the error state.
988
+ this.#hostControlSub = host.events.subscribe(() => this.#updateErrorState());
989
+ this.#updateErrorState();
990
+ }
991
+ }
992
+ #updateErrorState() {
993
+ const host = this.ngControl?.control;
994
+ this.errorState = FormUtils.getErrorState(this.fc) || (!!host && FormUtils.getErrorState(host));
611
995
  }
612
996
  #setLabels() {
613
997
  this.labels = {
@@ -2903,5 +3287,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImpo
2903
3287
  * Generated bundle index. Do not edit.
2904
3288
  */
2905
3289
 
2906
- export { CatalogComponent, DataGridComponent, DatetimeComponent, DatetimeRangeComponent, DynamicCatalogComponent, FormUtils, I18nCatalogComponent, NumberComponent, NumberRangeComponent, OrganizationComponent, OrganizationSetComponent, RangeSelectDateComponent, RangeSelectFilesizeComponent, ReferenceComponent, StringComponent, YuvFormsModule };
3290
+ export { CatalogComponent, ColorComponent, DataGridComponent, DatetimeComponent, DatetimeRangeComponent, DynamicCatalogComponent, FormUtils, I18nCatalogComponent, NumberComponent, NumberRangeComponent, OrganizationComponent, OrganizationSetComponent, RangeSelectDateComponent, RangeSelectFilesizeComponent, ReferenceComponent, StringComponent, YuvFormsModule };
2907
3291
  //# sourceMappingURL=yuuvis-client-framework-forms.mjs.map