src/app/core/admin/admin-entity-details/admin-entity-field/configure-entity-field-validator/configure-entity-field-validator.component.ts

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

entitySchemaField
Type : EntitySchemaField
Required :  true

the field definition with the currently existing validator settings to be edited

Outputs

entitySchemaFieldChanges
Type : Partial<EntitySchemaField>

Emit changes to EntitySchemaField properties (e.g. trim) that are not validators.

entityValidatorChanges
Type : FormValidatorConfig

Emit the latest state of the validators config whenever the user changed it in the displayed form.

Methods

removeDefaultValuesFromValidatorConfig
removeDefaultValuesFromValidatorConfig(validators: FormValidatorConfig)

Removes default fields and returns a validator config that only contains explicitly activated validators.

Parameters :
Name Type Optional Description
validators FormValidatorConfig No

form values including default values that are unchanged

Properties

isDateLikeValidatorType
Type : unknown
Default value : computed(() => { return ["date", "date-only", "date-with-age", "month"].includes( this.entitySchemaField()?.dataType, ); })
isDateWithAgeType
Type : unknown
Default value : computed(() => { return this.entitySchemaField()?.dataType === "date-with-age"; })
isMonthType
Type : unknown
Default value : computed(() => { return this.entitySchemaField()?.dataType === "month"; })
isStringType
Type : unknown
Default value : computed(() => { return ["string", "long-text"].includes(this.entitySchemaField()?.dataType); })
trimControl
Type : unknown
Default value : computed( () => new FormControl(this.entitySchemaField()?.trim !== false), )
validatorForm
Type : unknown
Default value : computed(() => { const v = this.entitySchemaField()?.validators; const isObjectForm = typeof v?.pattern === "object"; return this.fb.group({ required: [v?.required ?? false], min: [v?.min ?? null], max: [v?.max ?? null], minAge: [v?.minAge ?? null], maxAge: [v?.maxAge ?? null], minDate: [v?.minDate ?? null], maxDate: [v?.maxDate ?? null], pattern: [ (isObjectForm ? v.pattern.pattern : v?.pattern) ?? "", validRegexValidator, ], patternMessage: [(isObjectForm ? v.pattern.message : "") ?? ""], uniqueId: [v?.uniqueId ?? ""], readonlyAfterSet: [v?.readonlyAfterSet ?? false], }); })
import {
  Component,
  inject,
  input,
  output,
  ChangeDetectionStrategy,
  effect,
  computed,
} from "@angular/core";
import { MatInputModule } from "@angular/material/input";
import {
  AbstractControl,
  FormBuilder,
  FormControl,
  FormGroup,
  FormsModule,
  ReactiveFormsModule,
  ValidationErrors,
} from "@angular/forms";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatFormFieldModule } from "@angular/material/form-field";
import { EntitySchemaField } from "app/core/entity/schema/entity-schema-field";
import { FormValidatorConfig } from "app/core/common-components/entity-form/dynamic-form-validators/form-validator-config";
import { HelpButtonComponent } from "../../../../common-components/help-button/help-button.component";
import { EditDateComponent } from "../../../../basic-datatypes/date/edit-date/edit-date.component";
import { EditMonthComponent } from "../../../../basic-datatypes/month/edit-month/edit-month.component";

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-configure-entity-field-validator",
  imports: [
    MatInputModule,
    MatFormFieldModule,
    FormsModule,
    MatCheckboxModule,
    ReactiveFormsModule,
    HelpButtonComponent,
    EditDateComponent,
    EditMonthComponent,
  ],
  templateUrl: "./configure-entity-field-validator.component.html",
  styleUrl: "./configure-entity-field-validator.component.scss",
})
export class ConfigureEntityFieldValidatorComponent {
  private fb = inject(FormBuilder);

  /**
   * the field definition with the currently existing validator settings to be edited
   */
  entitySchemaField = input.required<EntitySchemaField>();

  /**
   * Emit the latest state of the validators config whenever the user changed it in the displayed form.
   */
  entityValidatorChanges = output<FormValidatorConfig>();

  /**
   * Emit changes to EntitySchemaField properties (e.g. trim) that are not validators.
   */
  entitySchemaFieldChanges = output<Partial<EntitySchemaField>>();

  trimControl = computed(
    () => new FormControl(this.entitySchemaField()?.trim !== false),
  );

  validatorForm = computed(() => {
    const v = this.entitySchemaField()?.validators;
    const isObjectForm = typeof v?.pattern === "object";
    return this.fb.group({
      required: [v?.required ?? false],
      min: [v?.min ?? null],
      max: [v?.max ?? null],
      minAge: [v?.minAge ?? null],
      maxAge: [v?.maxAge ?? null],
      minDate: [v?.minDate ?? null],
      maxDate: [v?.maxDate ?? null],
      pattern: [
        (isObjectForm ? v.pattern.pattern : v?.pattern) ?? "",
        validRegexValidator,
      ],
      patternMessage: [(isObjectForm ? v.pattern.message : "") ?? ""],
      uniqueId: [v?.uniqueId ?? ""],
      readonlyAfterSet: [v?.readonlyAfterSet ?? false],
    });
  });

  constructor() {
    effect((onCleanup) => {
      const form = this.validatorForm();

      this.normalizeDateControl(form, "minDate");
      this.normalizeDateControl(form, "maxDate");
      this.syncPatternMessageAvailability(form);

      const patternSub = form
        .get("pattern")
        .valueChanges.subscribe(() =>
          this.syncPatternMessageAvailability(form),
        );
      const sub = form.valueChanges.subscribe(() => {
        const rawValues = form.getRawValue();
        this.transformPatternValue(rawValues);
        const cleanedValues =
          this.removeDefaultValuesFromValidatorConfig(rawValues);
        this.entityValidatorChanges.emit(cleanedValues);
      });
      onCleanup(() => {
        patternSub.unsubscribe();
        sub.unsubscribe();
      });
    });

    effect((onCleanup) => {
      const ctrl = this.trimControl();
      const sub = ctrl.valueChanges.subscribe((value: boolean) => {
        this.entitySchemaFieldChanges.emit({ trim: value });
      });
      onCleanup(() => sub.unsubscribe());
    });

    effect((onCleanup) => {
      const ctrl = this.trimControl();
      const sub = ctrl.valueChanges.subscribe((value: boolean) => {
        this.entitySchemaFieldChanges.emit({ trim: value });
      });
      onCleanup(() => sub.unsubscribe());
    });
  }

  /**
   * The custom validation error text is only meaningful together with a pattern,
   * so the input is disabled while no pattern is entered.
   */
  private syncPatternMessageAvailability(form: FormGroup) {
    const messageControl = form.get("patternMessage");
    if (form.get("pattern").value) {
      messageControl.enable({ emitEvent: false });
    } else {
      messageControl.disable({ emitEvent: false });
    }
  }

  isDateLikeValidatorType = computed(() => {
    return ["date", "date-only", "date-with-age", "month"].includes(
      this.entitySchemaField()?.dataType,
    );
  });

  isStringType = computed(() => {
    return ["string", "long-text"].includes(this.entitySchemaField()?.dataType);
  });

  /**
   * Replaces the raw pattern and patternMessage inputs with a config value for the "pattern" validator:
   * drops it if the pattern is empty or not a compilable regex,
   * uses the object form { pattern, message } if a custom error message is entered.
   */
  private transformPatternValue(
    values: FormValidatorConfig & { patternMessage?: string },
  ) {
    const newPattern = values.pattern;
    const message = values.patternMessage;
    delete values.patternMessage;

    if (!newPattern || !isValidRegex(newPattern)) {
      delete values.pattern;
      return;
    }

    if (message) {
      values.pattern = { pattern: newPattern, message };
    }
  }

  isDateWithAgeType = computed(() => {
    return this.entitySchemaField()?.dataType === "date-with-age";
  });

  isMonthType = computed(() => {
    return this.entitySchemaField()?.dataType === "month";
  });

  private normalizeDateControl(form: FormGroup, controlName: string): void {
    const ctrl = form.get(controlName);
    const val = ctrl?.value;
    if (typeof val === "string" || typeof val === "number") {
      const parsed = new Date(val as any);
      if (!Number.isNaN(parsed.getTime())) {
        ctrl?.setValue(parsed, { emitEvent: false });
      }
    }
  }

  /**
   * Removes default fields and returns a validator config that only contains explicitly activated validators.
   * @param validators form values including default values that are unchanged
   */
  removeDefaultValuesFromValidatorConfig(
    validators: FormValidatorConfig,
  ): FormValidatorConfig {
    for (let key of Object.keys(validators)) {
      if (isDefaultValue(validators[key])) {
        delete validators[key];
      }
    }

    function isDefaultValue(value): boolean {
      return value === false || value === "" || value === null;
    }

    return validators;
  }
}

function isValidRegex(pattern: string): boolean {
  try {
    new RegExp(pattern);
    return true;
  } catch {
    return false;
  }
}

/**
 * Marks the control as invalid if its value cannot be compiled as a regular expression.
 */
function validRegexValidator(
  control: AbstractControl,
): ValidationErrors | null {
  if (!control.value || isValidRegex(control.value)) {
    return null;
  }
  return { invalidPattern: true };
}
<form class="validator-form-cell flex-column" [formGroup]="validatorForm()">
  @if (entitySchemaField().dataType === "number") {
    <mat-form-field class="number-field">
      <mat-label i18n>Minimum Value</mat-label>
      <input formControlName="min" matInput type="number" />
    </mat-form-field>
    <mat-form-field class="number-field">
      <mat-label i18n>Maximum Value</mat-label>
      <input formControlName="max" matInput type="number" />
    </mat-form-field>
  }

  @if (isDateLikeValidatorType()) {
    @if (isDateWithAgeType()) {
      <mat-form-field floatLabel="always">
        <mat-label i18n>Minimum Age (Years)</mat-label>
        <input formControlName="minAge" matInput type="number" />
        <app-help-button
          matSuffix
          text="Enter the minimum allowed age in years. This is independent from the date limits."
          i18n-text
        ></app-help-button>
      </mat-form-field>

      <mat-form-field floatLabel="always">
        <mat-label i18n>Maximum Age (Years)</mat-label>
        <input formControlName="maxAge" matInput type="number" />
        <app-help-button
          matSuffix
          text="Enter the maximum allowed age in years. This is independent from the date limits."
          i18n-text
        ></app-help-button>
      </mat-form-field>
    }

    @if (isMonthType()) {
      <mat-form-field floatLabel="always">
        <mat-label i18n>Minimum Month</mat-label>
        <app-edit-month formControlName="minDate"></app-edit-month>
      </mat-form-field>

      <mat-form-field floatLabel="always">
        <mat-label i18n>Maximum Month</mat-label>
        <app-edit-month formControlName="maxDate"></app-edit-month>
      </mat-form-field>
    } @else {
      <mat-form-field floatLabel="always">
        <mat-label i18n>Minimum Date</mat-label>
        <app-edit-date formControlName="minDate"></app-edit-date>
      </mat-form-field>

      <mat-form-field floatLabel="always">
        <mat-label i18n>Maximum Date</mat-label>
        <app-edit-date formControlName="maxDate"></app-edit-date>
      </mat-form-field>
    }
  }

  @if (isStringType()) {
    <div class="flex-row flex-wrap gap-small">
      <mat-form-field floatLabel="always" class="flex-grow">
        <mat-label i18n>Pattern (Regex)</mat-label>
        <input formControlName="pattern" matInput />
        <app-help-button
          matSuffix
          text="Define a format (-> regular expression) that text values have to match to be valid."
          i18n-text
        ></app-help-button>
        <mat-error i18n>Not a valid regular expression</mat-error>
        <mat-hint align="start" i18n>
          Learn about regular expressions at
          <a
            href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions"
            target="_blank"
            rel="noopener"
          >
            developer.mozilla.org
          </a>
        </mat-hint>
      </mat-form-field>

      <mat-form-field floatLabel="always" class="flex-grow">
        <mat-label i18n>Validation Error Text for Pattern</mat-label>
        <input formControlName="patternMessage" matInput />
        <app-help-button
          matSuffix
          text="Optional custom message shown to users when their input does not match the pattern. If empty, a generic error message is displayed."
          i18n-text
        ></app-help-button>
        <mat-hint align="start" i18n>
          Shown when the entered value does not match the pattern above
        </mat-hint>
      </mat-form-field>
    </div>
  }

  <div>
    <mat-checkbox i18n formControlName="required">
      Required Field
    </mat-checkbox>
    <app-help-button
      text="Users always must provide a value in this field"
      i18n-text
    ></app-help-button>
  </div>

  <div>
    <mat-checkbox i18n formControlName="uniqueId">
      Unique ID [experimental]</mat-checkbox
    >
    <app-help-button
      text="Ensures the value is not used as an ID for another existing record already. (Experimental Feature: Does not support advanced edge cases)"
      i18n-text
    ></app-help-button>
  </div>

  @if (entitySchemaField().dataType === "string") {
    <div>
      <mat-checkbox i18n [formControl]="trimControl()">
        Trim leading/trailing spaces automatically
      </mat-checkbox>
      <app-help-button
        text="When enabled, leading and trailing spaces are automatically removed when saving this field."
        i18n-text
      ></app-help-button>
    </div>
  }
</form>

./configure-entity-field-validator.component.scss

.validator-form-cell {
  width: 100%;
  gap: 1rem;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""