@yuuvis/client-framework 3.23.0 → 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,12 +1,12 @@
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
12
  import { CommonModule, NgClass } from '@angular/common';
@@ -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 {
@@ -242,30 +251,164 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImpo
242
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"] }]
243
252
  }] });
244
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
+
245
346
  class EditTableDataComponent {
246
347
  constructor() {
247
348
  this.#fb = inject(FormBuilder);
248
349
  this.#system = inject(SystemService);
249
350
  this.#errorMessage = inject(FormTranslateService);
351
+ this.#destroyRef = inject(DestroyRef);
352
+ this.#cdRef = inject(ChangeDetectorRef);
250
353
  this.#dialogData = inject(MAT_DIALOG_DATA);
251
354
  this.#dialogRef = inject((MatDialogRef));
252
355
  this.errorMessage = signal(null, ...(ngDevMode ? [{ debugName: "errorMessage" }] : /* istanbul ignore next */ []));
253
356
  this.tableForm = this.#fb.group({});
254
357
  this.isInnerTableForm = input(true, ...(ngDevMode ? [{ debugName: "isInnerTableForm" }] : /* istanbul ignore next */ []));
255
358
  this.formElement = this.#dialogData.formElement || {};
256
- this.columns = (this.formElement['elements'] || []).map((c) => ({
359
+ this.columns = signal((this.formElement['elements'] || []).map((c) => ({
257
360
  ...c,
258
361
  propertyType: c.type,
259
362
  _internalType: this.#system.getInternalFormElementType(c.type, c.classifications, c.catalog)
260
- }));
363
+ })), ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
261
364
  this.#elementData = this.#dialogData.elementData || {};
262
365
  this.situation = this.#dialogData.situation || Situation.EDIT;
263
366
  this.header = this.#dialogData.label || '';
264
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
+ };
265
406
  }
266
407
  #fb;
267
408
  #system;
268
409
  #errorMessage;
410
+ #destroyRef;
411
+ #cdRef;
269
412
  #dialogData;
270
413
  #dialogRef;
271
414
  #elementData;
@@ -275,16 +418,32 @@ class EditTableDataComponent {
275
418
  submit() {
276
419
  const data = this.tableForm.getRawValue();
277
420
  Object.keys(data).forEach((key) => (data[key] === '' || data[key] === undefined) && (data[key] = null));
278
- if (this.tableForm.valid && this.#dialogRef)
421
+ if (this.tableForm.valid && this.saveEnabled() && this.#dialogRef)
279
422
  this.#dialogRef.close(data);
280
423
  }
281
424
  ngOnInit() {
282
- Object.values(this.formElement['elements']).forEach((element) => {
283
- 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);
284
438
  });
285
439
  this.tableForm.setValidators(this.#globalErrorValidator());
286
440
  this.#elementData && this.tableForm.patchValue(this.#elementData);
287
- 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(() => {
288
447
  this.errorMessage.set(this.tableForm.errors
289
448
  ? Object.keys(this.tableForm.errors).map((key) => this.tableForm.errors && this.tableForm.errors[key]
290
449
  ? Object.keys(this.tableForm.errors[key]).map((k) => ({
@@ -294,6 +453,57 @@ class EditTableDataComponent {
294
453
  : null);
295
454
  });
296
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
+ }
297
507
  #globalErrorValidator() {
298
508
  return (control) => {
299
509
  const formGroup = control;
@@ -324,7 +534,7 @@ class EditTableDataComponent {
324
534
  useExisting: forwardRef(() => EditTableDataComponent),
325
535
  multi: true
326
536
  }
327
- ], 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 }); }
328
538
  }
329
539
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImport: i0, type: EditTableDataComponent, decorators: [{
330
540
  type: Component,
@@ -343,7 +553,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImpo
343
553
  useExisting: forwardRef(() => EditTableDataComponent),
344
554
  multi: true
345
555
  }
346
- ], 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"] }]
347
557
  }], propDecorators: { isInnerTableForm: [{ type: i0.Input, args: [{ isSignal: true, alias: "isInnerTableForm", required: false }] }] } });
348
558
 
349
559
  var DataGridSizeType;
@@ -355,6 +565,7 @@ var DataGridSizeType;
355
565
  DataGridSizeType["AUTO"] = "auto";
356
566
  })(DataGridSizeType || (DataGridSizeType = {}));
357
567
 
568
+ const isRowObject = (row) => typeof row === 'object' && row !== null;
358
569
  const MATERIAL = [MatMenuModule, MatMenuTrigger, MatIconModule, MatButtonModule, MatTableModule, MatTooltipModule];
359
570
  class DataGridComponent {
360
571
  constructor() {
@@ -380,6 +591,12 @@ class DataGridComponent {
380
591
  : formElement && formElement['label'];
381
592
  }, ...(ngDevMode ? [{ debugName: "tableLabel" }] : /* istanbul ignore next */ []));
382
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 = [];
383
600
  this.isRequired = false;
384
601
  this.isInvalid = false;
385
602
  this.icons = {
@@ -404,7 +621,12 @@ class DataGridComponent {
404
621
  const data = this.#toRowObjects(formElement ? formElement['value'] || [] : [], elements);
405
622
  // rendering only — the value already lives in the form model, so a (re)bind
406
623
  // of the formElement input must never propagate back or dirty the control
407
- untracked(() => formElement && this.#updateTable(elements, data));
624
+ untracked(() => {
625
+ if (!formElement)
626
+ return;
627
+ this.#resetPersistedRows(data);
628
+ this.#updateTable(elements, data);
629
+ });
408
630
  }, ...(ngDevMode ? [{ debugName: "#loadData" }] : /* istanbul ignore next */ []));
409
631
  this.propagateChange = () => { };
410
632
  this.onTouched = () => { };
@@ -412,6 +634,12 @@ class DataGridComponent {
412
634
  #dialog;
413
635
  #systemService;
414
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;
415
643
  #loadData;
416
644
  // if the form element is created from the ObjectTypeField it contains 'columnDefinitions' instead of 'elements'
417
645
  // therefore we need to map them here for proper table rendering
@@ -443,7 +671,7 @@ class DataGridComponent {
443
671
  return row;
444
672
  });
445
673
  }
446
- #openEditOverlay(elementData, adding = false) {
674
+ #openEditOverlay(elementData, adding = false, rowIndex = -1, persisted = false) {
447
675
  return this.#dialog
448
676
  .open(EditTableDataComponent, {
449
677
  minWidth: '25%',
@@ -454,7 +682,8 @@ class DataGridComponent {
454
682
  elementData,
455
683
  situation: this.situation(),
456
684
  adding,
457
- label: this.tableLabel()
685
+ label: this.tableLabel(),
686
+ rowEdit: this.#rowEditContext(rowIndex, persisted)
458
687
  }
459
688
  })
460
689
  .afterClosed()
@@ -467,8 +696,13 @@ class DataGridComponent {
467
696
  if (this.readonly())
468
697
  return;
469
698
  this.selectedRow.set(element);
470
- 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) => {
471
702
  if (result) {
703
+ // an edited row keeps its persisted state
704
+ if (persisted)
705
+ this.#persistedRows.add(result);
472
706
  const updatedData = this.dataSource().map((item) => JSON.stringify(item) === JSON.stringify(element) ? result : item);
473
707
  const formElement = this.mappedFormElement();
474
708
  if (formElement) {
@@ -493,6 +727,43 @@ class DataGridComponent {
493
727
  }
494
728
  });
495
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
+ }
496
767
  removeRow(element) {
497
768
  const updatedData = this.dataSource().filter((item) => JSON.stringify(item) !== JSON.stringify(element));
498
769
  const formElement = this.mappedFormElement();
@@ -544,7 +815,9 @@ class DataGridComponent {
544
815
  */
545
816
  writeValue(obj) {
546
817
  const elements = this.#resolveElements(this.formElement());
547
- 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);
548
821
  }
549
822
  registerOnChange(fn) {
550
823
  this.propagateChange = fn;
@@ -625,6 +898,7 @@ class DatetimeComponent extends AbstractMatFormField {
625
898
  this.pickerCmp = viewChild.required(DatepickerComponent);
626
899
  this.ngControl = injectNgControl(this);
627
900
  this.fc = new FormControl(null);
901
+ this.#hostControl = null;
628
902
  this._locale = signal(this.translate.getCurrentLang(), ...(ngDevMode ? [{ debugName: "_locale" }] : /* istanbul ignore next */ []));
629
903
  this.locale = input(this.translate.getCurrentLang(), ...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
630
904
  this.#localeEffect = effect(() => {
@@ -650,6 +924,8 @@ class DatetimeComponent extends AbstractMatFormField {
650
924
  this.propagateChange = (_) => { };
651
925
  }
652
926
  #dRef;
927
+ #hostControl;
928
+ #hostControlSub;
653
929
  #localeEffect;
654
930
  openCalendar() {
655
931
  this.pickerCmp().openCalendar();
@@ -693,11 +969,29 @@ class DatetimeComponent extends AbstractMatFormField {
693
969
  this.translate.onLangChange.subscribe((evt) => this._locale.set(evt.lang));
694
970
  this.fc.valueChanges.pipe(takeUntilDestroyed(this.#dRef)).subscribe((value) => this.onValueChange(value));
695
971
  this.fc.statusChanges.pipe(takeUntilDestroyed(this.#dRef)).subscribe(() => {
696
- this.errorState = FormUtils.getErrorState(this.fc);
697
972
  if (this.ngControl?.control) {
698
- this.ngControl.control.setErrors(this.fc.errors);
973
+ FormUtils.mergeInnerErrors(this.ngControl.control, this.fc);
699
974
  }
975
+ this.#updateErrorState();
700
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));
701
995
  }
702
996
  #setLabels() {
703
997
  this.labels = {