src/app/core/common-components/conditions-editor/conditions-editor.component.ts

Description

Reusable component for editing conditions (field-value pairs) with JSON support

Implements

OnInit

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Inputs

conditions
Type : any
Default value : {}
disabled
Type : boolean
Default value : false
entityConstructor
Type : EntityConstructor
label
Type : any
Default value : $localize`Edit JSON`
showInternalIdField
Type : boolean
Default value : false

also offer the internal "_id" field in the field dropdown (e.g. for permission conditions)

Outputs

conditionsChange
Type : EventEmitter

Methods

addCondition
addCondition()

Add a new condition

Returns : void
deleteCondition
deleteCondition(conditionIndex: number)

Delete a condition

Parameters :
Name Type Optional
conditionIndex number No
Returns : void
getConditionField
getConditionField(condition: any)

Get the field key from a condition object (static helper)

Parameters :
Name Type Optional
condition any No
Returns : string
isNegated
isNegated(conditionIndex: number)

whether the given row's stored condition is negated

Parameters :
Name Type Optional
conditionIndex number No
Returns : boolean
onConditionFieldChange
onConditionFieldChange(conditionIndex: number, fieldKey: string | string[])

Handle condition field change

Parameters :
Name Type Optional
conditionIndex number No
fieldKey string | string[] No
Returns : void
openJsonEditor
openJsonEditor()

Open JSON editor for conditions

Returns : void
setCombinator
setCombinator(combinator: Combinator)
Parameters :
Name Type Optional
combinator Combinator No
Returns : void
setNegated
setNegated(conditionIndex: number, negated: boolean)

switch a row between "is" and "is not", keeping its value

Parameters :
Name Type Optional
conditionIndex number No
negated boolean No
Returns : void

Properties

Readonly combinator
Type : unknown
Default value : signal<Combinator>(DEFAULT_COMBINATOR)

whether every row has to match ("all", and) or any one row ("any", or)

Readonly combinatorHint
Type : unknown
Default value : computed(() => this.combinator() === "any" ? $localize`Matches if any one of the conditions applies ("or" conditions).` : $localize`Matches only if all conditions apply ("and" conditions).`, )
conditionFormControls
Type : unknown
Default value : new Map<string, FormControl>()
conditionFormFieldConfigs
Type : unknown
Default value : new Map<string, FormFieldConfig>()
conditionsArray
Type : unknown
Default value : computed(() => this.conditionsSignal()?.$or || [])

Computed signal for the conditions array

import {
  Component,
  Input,
  Output,
  EventEmitter,
  OnInit,
  inject,
  computed,
  input,
  signal,
  ChangeDetectionStrategy,
} from "@angular/core";
import { MatButtonModule } from "@angular/material/button";
import { MatSelectModule } from "@angular/material/select";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatButtonToggleModule } from "@angular/material/button-toggle";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { EntityConstructor } from "app/core/entity/model/entity";
import { FormFieldConfig } from "app/core/common-components/entity-form/FormConfig";
import { EntitySchemaService } from "app/core/entity/schema/entity-schema.service";
import { DynamicEditComponent } from "app/core/entity/entity-field-edit/dynamic-edit/dynamic-edit.component";
import { MatDialog } from "@angular/material/dialog";
import { JsonEditorDialogComponent } from "app/core/admin/json-editor/json-editor-dialog/json-editor-dialog.component";
import { EntityFieldSelectComponent } from "app/core/entity/entity-field-select/entity-field-select.component";
import { IconButtonComponent } from "../icon-button/icon-button.component";
import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { negate, splitNegation, withSameNegation } from "./condition-negation";
import {
  buildConditions,
  Combinator,
  DEFAULT_COMBINATOR,
  parseConditions,
} from "./conditions-combinator";

/**
 * Reusable component for editing conditions (field-value pairs) with JSON support
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-conditions-editor",
  templateUrl: "./conditions-editor.component.html",
  styleUrls: ["./conditions-editor.component.scss"],
  imports: [
    MatButtonModule,
    MatSelectModule,
    MatFormFieldModule,
    MatTooltipModule,
    MatButtonToggleModule,
    IconButtonComponent,
    FontAwesomeModule,
    DynamicEditComponent,
    ReactiveFormsModule,
    EntityFieldSelectComponent,
  ],
})
export class ConditionsEditorComponent implements OnInit {
  @Input() conditions: any = {};
  @Input() entityConstructor?: EntityConstructor;
  @Input() disabled = false;
  @Input() label = $localize`Edit JSON`;

  /** whether every row has to match ("all", and) or any one row ("any", or) */
  readonly combinator = signal<Combinator>(DEFAULT_COMBINATOR);

  readonly combinatorHint = computed(() =>
    this.combinator() === "any"
      ? $localize`Matches if any one of the conditions applies ("or" conditions).`
      : $localize`Matches only if all conditions apply ("and" conditions).`,
  );

  /** also offer the internal "_id" field in the field dropdown (e.g. for permission conditions) */
  readonly showInternalIdField = input(false);

  @Output() conditionsChange = new EventEmitter<any>();

  private readonly conditionsSignal = signal<any>({});

  /**
   * The rows being edited, as `{ $or: rows }`. It is not the `conditions` input: parents that
   * store what the editor emits bind it straight back into that input, which is only read once,
   * when the editor starts.
   */
  private working: { $or?: any[] } = {};

  conditionFormFieldConfigs = new Map<string, FormFieldConfig>();
  conditionFormControls = new Map<string, FormControl>();

  private readonly entitySchemaService = inject(EntitySchemaService);
  private readonly dialog = inject(MatDialog);

  ngOnInit(): void {
    if (!this.entityConstructor) return;
    this.loadConditions(this.conditions);
    this.rebuildFormConfigs();
  }

  /**
   * Computed signal for the conditions array
   */
  conditionsArray = computed(() => this.conditionsSignal()?.$or || []);

  /**
   * Get the field key from a condition object (static helper)
   */
  getConditionField(condition: any): string {
    return Object.keys(condition || {})[0] || "";
  }

  /** whether the given row's stored condition is negated */
  isNegated(conditionIndex: number): boolean {
    const condition = this.conditionsArray()[conditionIndex];
    const fieldKey = this.getConditionField(condition);
    return splitNegation(condition?.[fieldKey]).negated;
  }

  /** switch a row between "is" and "is not", keeping its value */
  setNegated(conditionIndex: number, negated: boolean): void {
    const condition = this.conditionsArray()[conditionIndex];
    const fieldKey = this.getConditionField(condition);
    if (!fieldKey) return;

    const { positive } = splitNegation(condition[fieldKey]);
    condition[fieldKey] = negated ? negate(positive) : positive;

    this.conditionsSignal.set({ ...this.working });
    this.emitConditions();
  }

  setCombinator(combinator: Combinator): void {
    this.combinator.set(combinator);
    this.emitConditions();
  }

  /**
   * Add a new condition
   */
  addCondition(): void {
    if (!this.working.$or) {
      this.working.$or = [];
    }

    this.working.$or.push({});
    this.conditionsSignal.set({ ...this.working });
    this.emitConditions();
  }

  /**
   * Delete a condition
   */
  deleteCondition(conditionIndex: number): void {
    const conditions = this.conditionsArray();
    if (conditionIndex < 0 || conditionIndex >= conditions.length) return;

    conditions.splice(conditionIndex, 1);

    if (conditions.length === 0) {
      this.working = {};
      this.conditionsSignal.set({});
    } else {
      this.conditionsSignal.set({ ...this.working });
    }

    this.rebuildFormConfigs();
    this.emitConditions();
  }

  /**
   * Handle condition field change
   */
  onConditionFieldChange(
    conditionIndex: number,
    fieldKey: string | string[],
  ): void {
    const conditions = this.conditionsArray();
    if (conditionIndex < 0 || conditionIndex >= conditions.length) return;

    const actualFieldKey = Array.isArray(fieldKey) ? fieldKey[0] : fieldKey;
    if (!actualFieldKey) return;

    const condition = conditions[conditionIndex];
    const currentFieldKey = this.getConditionField(condition);

    // Only update if the field actually changed
    if (currentFieldKey === actualFieldKey) return;

    Object.keys(condition).forEach((key) => delete condition[key]);
    condition[actualFieldKey] = null;
    this.conditionsSignal.set({ ...this.working });

    this.createFormConfigForCondition(conditionIndex, actualFieldKey);
    this.emitConditions();
  }

  /**
   * Create form configuration for a specific condition
   */
  private createFormConfigForCondition(
    conditionIndex: number,
    fieldKey: string,
  ): void {
    const key = `${conditionIndex}`;
    const fieldConfig = this.entityConstructor.schema.get(fieldKey);
    if (!fieldConfig) return;
    const editComponent = this.entitySchemaService.getComponent(
      fieldConfig,
      "edit",
    );
    const isDropdownMultiSelect = this.shouldUseMultiSelectCondition(
      fieldConfig,
      editComponent,
    );
    const conditionFieldConfig = {
      ...fieldConfig,
      isArray: isDropdownMultiSelect,
    };

    const conditions = this.conditionsArray();
    const condition = conditions[conditionIndex];

    const initialValue = this.extractValueFromCondition(
      condition[fieldKey],
      fieldConfig,
      conditionFieldConfig,
    );
    const formControl = new FormControl(initialValue);
    this.conditionFormControls.set(key, formControl);

    formControl.valueChanges.subscribe((value) =>
      this.onFormValueChange(
        condition,
        fieldKey,
        value,
        fieldConfig,
        conditionFieldConfig,
      ),
    );

    this.conditionFormFieldConfigs.set(key, {
      id: fieldKey,
      editComponent,
      dataType: fieldConfig.dataType,
      additional: fieldConfig.additional,
      label: fieldConfig.label || fieldKey,
      isArray: isDropdownMultiSelect,
    } as FormFieldConfig);
  }

  private extractValueFromCondition(
    conditionValue: any,
    fieldConfig: EntitySchemaField,
    conditionFieldConfig: EntitySchemaField,
  ): any {
    // a negated condition still edits the value inside it
    const { positive } = splitNegation(conditionValue);

    let value;
    if (fieldConfig.isArray && positive?.$elemMatch?.$in) {
      // For array fields, extract value from $elemMatch.$in if present
      value = positive.$elemMatch.$in;
    } else if (fieldConfig.isArray && positive?.$elemMatch !== undefined) {
      // Support legacy array-condition format: { $elemMatch: "id" }
      const elemMatch = positive.$elemMatch;
      if (Array.isArray(elemMatch)) {
        value = elemMatch;
      } else {
        value = [elemMatch];
      }
    } else if (!fieldConfig.isArray && positive?.$in) {
      // For non-array dropdown fields, extract value from $in
      value = positive.$in;
    } else if (positive?.$eq !== undefined) {
      // a negated plain value is stored as $eq, which the value control cannot render
      value = positive.$eq;
    } else {
      value = positive;
    }

    return this.entitySchemaService.valueToEntityFormat(
      value,
      conditionFieldConfig,
    );
  }

  private onFormValueChange(
    condition: any,
    fieldKey: string,
    value: any,
    fieldConfig: EntitySchemaField,
    conditionFieldConfig: EntitySchemaField,
  ): void {
    const previous = condition[fieldKey];

    const dbValue = this.entitySchemaService.valueToDatabaseFormat(
      value,
      conditionFieldConfig,
    );

    let positive: any;
    if (fieldConfig.isArray && Array.isArray(dbValue) && dbValue.length > 0) {
      // For array fields, wrap in $elemMatch with $in for proper array matching
      positive = { $elemMatch: { $in: dbValue } };
    } else if (
      !fieldConfig.isArray &&
      conditionFieldConfig.isArray &&
      Array.isArray(dbValue) &&
      dbValue.length > 0
    ) {
      // For non-array dropdown fields, wrap multi selection in $in
      positive = { $in: dbValue };
    } else {
      positive = dbValue;
    }

    condition[fieldKey] = withSameNegation(previous, positive);

    this.emitConditions();
  }

  private shouldUseMultiSelectCondition(
    fieldConfig: EntitySchemaField,
    editComponent: string,
  ): boolean {
    if (fieldConfig.isArray) {
      return true;
    }

    return (
      editComponent === "EditConfigurableEnum" ||
      editComponent === "EditEntity" ||
      editComponent === "EditEntityType"
    );
  }

  /**
   * Rebuild form configs for all conditions
   */
  private rebuildFormConfigs(): void {
    this.conditionFormFieldConfigs.clear();
    this.conditionFormControls.clear();

    this.conditionsArray().forEach((condition, index) => {
      const fieldKey = this.getConditionField(condition);
      if (fieldKey) {
        this.createFormConfigForCondition(index, fieldKey);
      }
    });
  }

  /**
   * Open JSON editor for conditions
   */
  openJsonEditor(): void {
    const dialogRef = this.dialog.open(JsonEditorDialogComponent, {
      data: {
        value: buildConditions(this.combinator(), this.working.$or ?? []),
        closeButton: true,
      },
    });

    dialogRef.afterClosed().subscribe((result) => {
      if (result) {
        this.loadConditions(result);
        this.rebuildFormConfigs();
        this.emitConditions();
      }
    });
  }

  /** The stored condition becomes the editor's working state: its combinator and its rows. */
  private loadConditions(stored: any): void {
    const { combinator, rows } = parseConditions(stored);
    this.combinator.set(combinator);
    this.working = rows.length > 0 ? { $or: rows } : {};
    this.conditionsSignal.set(this.working);
  }

  /** Report the rows in their stored shape; rows that are not filled in yet stay out of it. */
  private emitConditions(): void {
    this.conditionsChange.emit(
      buildConditions(this.combinator(), this.working.$or ?? []),
    );
  }
}
<div class="flex-row align-center gap-regular margin-bottom-regular">
  <mat-button-toggle-group
    [value]="combinator()"
    (change)="setCombinator($event.value)"
    [disabled]="disabled"
    hideSingleSelectionIndicator
  >
    <mat-button-toggle
      value="any"
      matTooltip='A record matches as soon as one of the conditions below applies ("or").'
      i18n-matTooltip
      i18n
    >
      Any
    </mat-button-toggle>
    <mat-button-toggle
      value="all"
      matTooltip='A record matches only if every one of the conditions below applies ("and").'
      i18n-matTooltip
      i18n
    >
      All
    </mat-button-toggle>
  </mat-button-toggle-group>
  <span i18n>of the following conditions match</span>
</div>

<div class="conditions-container">
  <div class="conditions-list flex-column">
    @for (
      condition of conditionsArray();
      track $index;
      let conditionId = $index
    ) {
      <div class="condition-item flex-row flex-wrap gap-regular align-center">
        <mat-form-field class="field-selector">
          <mat-label i18n>Field</mat-label>
          <app-entity-field-select
            [entityType]="entityConstructor"
            [value]="getConditionField(condition)"
            [showInternalIdField]="showInternalIdField()"
            (valueChange)="onConditionFieldChange(conditionId, $event)"
          ></app-entity-field-select>
        </mat-form-field>

        <mat-form-field class="operator-select">
          <mat-label i18n>Condition</mat-label>
          <mat-select
            [value]="isNegated(conditionId)"
            (valueChange)="setNegated(conditionId, $event)"
            [disabled]="disabled"
          >
            <mat-option [value]="false" i18n>is</mat-option>
            <mat-option [value]="true" i18n>is not</mat-option>
          </mat-select>
        </mat-form-field>

        @if (
          conditionFormFieldConfigs.get(conditionId + "");
          as formFieldConfig
        ) {
          @if (conditionFormControls.get(conditionId + ""); as formControl) {
            <mat-form-field class="value-input" floatLabel="always">
              <mat-label>{{ formFieldConfig.label }}</mat-label>
              <app-dynamic-edit
                [formFieldConfig]="formFieldConfig"
                [formControl]="formControl"
              ></app-dynamic-edit>
            </mat-form-field>
          }
        }

        <button
          mat-icon-button
          type="button"
          style="margin: auto"
          color="warn"
          (click)="deleteCondition(conditionId)"
          matTooltip="Delete condition"
          i18n-matTooltip
        >
          <fa-icon icon="trash"></fa-icon>
        </button>
      </div>
    }

    <div class="hint margin-top-regular">{{ combinatorHint() }}</div>

    <div class="button-row flex-row gap-small">
      <app-icon-button icon="plus" (buttonClick)="addCondition()" i18n>
        Add Condition
      </app-icon-button>

      <app-icon-button icon="code" (buttonClick)="openJsonEditor()">
        {{ label }}
      </app-icon-button>
    </div>
  </div>
</div>

./conditions-editor.component.scss

.conditions-container {
  background-color: #fef5f5;
  border-radius: 8px;
  padding: 16px;
}

.conditions-list {
  margin-top: 0;
}

.condition-item {
  align-items: end;
}

.field-selector {
  // shrinks before the value input does, but keeps the field name readable
  flex: 1 1 200px;
  min-width: 160px;
}

.operator-select {
  flex: 0 0 110px;
}

.value-input {
  // wraps to its own line rather than being squeezed unreadably narrow
  flex: 1 1 220px;
  min-width: 200px;
}

.hint {
  font-style: italic;
}

.button-row {
  margin-top: 16px;
}

.add-condition-button {
  align-self: flex-start;
}

.edit-json-button {
  align-self: flex-start;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""