src/app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component.ts

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

config
Type : FormConfig
entityType
Type : EntityConstructor
fieldsOnlyMode
Type : boolean

Whether to only show fields in a compact layout. If false, the full admin layout with section headers and drag&drop areas is shown.

isDisabled
Type : boolean

Whether the UI is readonly, not allowing the user to drag or edit things.

uniqueAreaId
Type : string
updateEntitySchema
Type : boolean
Default value : true

Also update any changes to fields to the global entity type schema.

Outputs

configChange
Type : FormConfig

Methods

clearSearch
clearSearch()
Returns : void
Async drop
drop(event: FieldDragDropEvent)

Move a field that was dragged into a field group or back into the toolbar.

Each drop list carries its identity (a field group's index or "available") as cdkDropListData and each field carries itself as cdkDragData, so that the new fieldGroups can be built immutably instead of splicing the arrays CDK hands over.

Parameters :
Name Type Optional
event FieldDragDropEvent No
Returns : any
dropFieldGroups
dropFieldGroups(event: CdkDragDrop)

Reorder the field groups (the form's columns).

Parameters :
Name Type Optional
event CdkDragDrop<unknown> No
Returns : void
Async dropNewGroup
dropNewGroup(event: FieldDragDropEvent)

Move the dragged field into a new field group appended to the form.

Parameters :
Name Type Optional
event FieldDragDropEvent No
Returns : any
hideField
hideField(field: ColumnConfig, group: FieldGroup)
Parameters :
Name Type Optional
field ColumnConfig No
group FieldGroup No
Returns : void
Async openConfigDetails
openConfigDetails(field: ColumnConfig)

Opens the configuration settings for a field. If the field has an editComponent defined in the schema, it opens the text configuration. Otherwise, it opens the field configuration.

Parameters :
Name Type Optional Description
field ColumnConfig No

The field to open the configuration settings for.

Returns : any
Async openFieldConfig
openFieldConfig(field: ColumnConfig)

Open the form to edit details of a single field's schema.

Parameters :
Name Type Optional Description
field ColumnConfig No

field to edit or { id: null } to create a new field

the id of the field that was edited or created (which is newly defined in the dialog for new fields)

Async openTextConfig
openTextConfig(textField: FormFieldConfig)

Open the form to edit details of a single text's schema.

Parameters :
Name Type Optional Description
textField FormFieldConfig No

text to edit or { id: null } to create a new text

the id of the text that was edited or created (which is newly defined in the dialog for new fields)

removeGroup
removeGroup(i: number)
Parameters :
Name Type Optional
i number No
Returns : void
updateGroupHeader
updateGroupHeader(i: number, header: string)
Parameters :
Name Type Optional
i number No
header string No
Returns : void

Properties

availableFields
Type : unknown
Default value : linkedSignal<ColumnConfig[]>(() => this.computeAvailableFieldsList(), )
Readonly availableFieldsDropListId
Type : unknown
Default value : computed( () => `availableFields-${this.uniqueAreaId()}`, )

id of the toolbar's drop list, so that fields can be dragged out of the form again

Readonly availableFieldsTarget
Type : FieldDropTarget
Default value : "available"

cdkDropListData marking the toolbar as the drop target for fields removed from the form

Readonly connectedGroups
Type : unknown
Default value : computed(() => { const config = this.config(); const areaId = this.uniqueAreaId(); if (!config) { return [`newGroupDropArea-${areaId}`, this.availableFieldsDropListId()]; } return [ ...this.fieldGroups().map( (_, groupIndex) => `${areaId}-group${groupIndex}`, ), `newGroupDropArea-${areaId}`, this.availableFieldsDropListId(), ]; })

Ids of all drop lists a field can be dragged into: every field group, the area creating a new group, and the toolbar of fields not used in the form.

Readonly createNewFieldPlaceholder
Type : FormFieldConfig
Default value : { id: null, label: $localize`:Label drag and drop item:Create New Field`, }
Readonly createNewTextPlaceholder
Type : FormFieldConfig
Default value : { id: null, label: $localize`:Label drag and drop item:Create Text Block`, }
Readonly dummyEntity
Type : unknown
Default value : signal<Entity | undefined>(undefined)

Dummy entity and form backing the form preview. These are signals so that rebuilding them updates the rendered field components, which extend their config (e.g. the label) from the entity type's schema.

Readonly dummyForm
Type : unknown
Default value : signal<EntityForm<any> | undefined>(undefined)
fieldGroups
Type : unknown
Default value : linkedSignal<FormConfig | undefined, FieldGroup[]>({ source: this.config, computation: (config, previous) => { const incoming = dropEmptyFields(config?.fieldGroups ?? []); if ( previous && JSON.stringify(previous.value) === JSON.stringify(incoming) ) { // the config input only re-states what this component emitted itself, // so keep the existing objects instead of replacing them with clones return previous.value; } return structuredClone(incoming); }, })

Writable working copy of the config's fieldGroups. Derives from the config input and can be mutated locally. availableFields and connectedGroups derive from this signal, so all structural changes automatically propagate.

filteredFields
Type : unknown
Default value : computed(() => { const searchTerm = this.searchFieldSignal()?.toLowerCase().trim() || ""; const fields = this.availableFields(); if (!searchTerm) { return fields; } return fields.filter((field) => { // always show the create new field and create new text placeholders if ( field === this.createNewFieldPlaceholder || field === this.createNewTextPlaceholder ) { return true; } const fieldConfig = this.entityFormService?.extendFormFieldConfig( field, this.entityType(), ) || toFormFieldConfig(field); const fieldId = fieldConfig.id?.toLowerCase() || ""; const fieldLabel = fieldConfig.label?.toLowerCase() || ""; return fieldId.includes(searchTerm) || fieldLabel.includes(searchTerm); }); })
searchFilter
Type : unknown
Default value : new FormControl("")
import { EntityForm } from "#src/app/core/common-components/entity-form/entity-form";
import {
  CdkDragDrop,
  DragDropModule,
  moveItemInArray,
} from "@angular/cdk/drag-drop";
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  effect,
  inject,
  input,
  linkedSignal,
  output,
  signal,
  untracked,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatCardModule } from "@angular/material/card";
import { MatDialog } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatIconModule } from "@angular/material/icon";
import { MatInputModule } from "@angular/material/input";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { FieldGroup } from "app/core/entity-details/form/field-group";
import { EntitySchemaField } from "app/core/entity/schema/entity-schema-field";
import { lastValueFrom } from "rxjs";
import { EntityFormService } from "../../../common-components/entity-form/entity-form.service";
import {
  ColumnConfig,
  FormFieldConfig,
  toFormFieldConfig,
} from "../../../common-components/entity-form/FormConfig";
import { FormConfig } from "../../../entity-details/form/form.component";
import { EntityFieldEditComponent } from "../../../entity/entity-field-edit/entity-field-edit.component";
import { EntityFieldLabelComponent } from "../../../entity/entity-field-label/entity-field-label.component";
import { Entity, EntityConstructor } from "../../../entity/model/entity";
import { AdminEntityService } from "../../admin-entity.service";
import { AdminSectionHeaderComponent } from "../../building-blocks/admin-section-header/admin-section-header.component";
import { AdminEditDescriptionOnlyFieldComponent } from "../admin-entity-field/admin-edit-description-only-field/admin-edit-description-only-field.component";
import {
  AdminEntityFieldComponent,
  AdminEntityFieldData,
} from "../admin-entity-field/admin-entity-field.component";

/**
 * Identifies one of the drop lists a field can be dragged between:
 * the index of a field group in the form, or the toolbar of fields not used in the form.
 */
export type FieldDropTarget = number | "available";

/** A field dragged between the form's field groups and the toolbar of unused fields. */
type FieldDragDropEvent = CdkDragDrop<
  FieldDropTarget,
  FieldDropTarget,
  ColumnConfig
>;

@UntilDestroy()
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-admin-entity-form",
  templateUrl: "./admin-entity-form.component.html",
  styleUrls: [
    "./admin-entity-form.component.scss",
    "../../building-blocks/admin-section-header/admin-section-header.component.scss",
    "../../../common-components/entity-form/entity-form/entity-form.component.scss",
  ],
  imports: [
    DragDropModule,
    FaIconComponent,
    MatButtonModule,
    MatTooltipModule,
    MatCardModule,
    MatFormFieldModule,
    MatIconModule,
    MatInputModule,
    ReactiveFormsModule,
    EntityFieldLabelComponent,
    EntityFieldEditComponent,
    AdminSectionHeaderComponent,
  ],
})
export class AdminEntityFormComponent {
  private entityFormService = inject(EntityFormService);
  private matDialog = inject(MatDialog);
  private adminEntityService = inject(AdminEntityService);
  private readonly destroyRef = inject(DestroyRef);

  // migrate inputs to Angular `input()` signals
  readonly entityType = input<EntityConstructor>();
  readonly uniqueAreaId = input<string>();

  // `config` as an Input signal. Call `this.config()` to access the value.
  readonly config = input<FormConfig>();

  readonly configChange = output<FormConfig>();

  /**
   * Whether the UI is readonly, not allowing the user to drag or edit things.
   */
  readonly isDisabled = input<boolean>();

  /**
   * Also update any changes to fields to the global entity type schema.
   */
  readonly updateEntitySchema = input<boolean>(true);

  /** Whether to only show fields in a compact layout.
   * If false, the full admin layout with section headers and drag&drop areas is shown.
   */
  readonly fieldsOnlyMode = input<boolean>();

  /**
   * Dummy entity and form backing the form preview.
   * These are signals so that rebuilding them updates the rendered field components,
   * which extend their config (e.g. the label) from the entity type's schema.
   */
  readonly dummyEntity = signal<Entity | undefined>(undefined);
  readonly dummyForm = signal<EntityForm<any> | undefined>(undefined);

  availableFields = linkedSignal<ColumnConfig[]>(() =>
    this.computeAvailableFieldsList(),
  );

  /**
   * Writable working copy of the config's fieldGroups.
   * Derives from the `config` input and can be mutated locally.
   * `availableFields` and `connectedGroups` derive from this signal,
   * so all structural changes automatically propagate.
   */
  fieldGroups = linkedSignal<FormConfig | undefined, FieldGroup[]>({
    source: this.config,
    computation: (config, previous) => {
      const incoming = dropEmptyFields(config?.fieldGroups ?? []);
      if (
        previous &&
        JSON.stringify(previous.value) === JSON.stringify(incoming)
      ) {
        // the config input only re-states what this component emitted itself,
        // so keep the existing objects instead of replacing them with clones
        return previous.value;
      }
      return structuredClone(incoming);
    },
  });
  readonly createNewFieldPlaceholder: FormFieldConfig = {
    id: null,
    label: $localize`:Label drag and drop item:Create New Field`,
  };

  readonly createNewTextPlaceholder: FormFieldConfig = {
    id: null,
    label: $localize`:Label drag and drop item:Create Text Block`,
  };

  /** `cdkDropListData` marking the toolbar as the drop target for fields removed from the form */
  readonly availableFieldsTarget: FieldDropTarget = "available";

  searchFilter = new FormControl("");

  private readonly searchFieldSignal = toSignal(
    this.searchFilter.valueChanges,
    {
      initialValue: "",
    },
  );

  /**
   * Configurations of the fields currently used in the form (null while not initialized yet).
   * The dummy form only has to be rebuilt when these change,
   * not when other details like a field group header are edited.
   */
  private readonly usedFieldConfigurations = computed(() => {
    if (!this.config() || !this.entityType()) {
      return null;
    }
    return JSON.stringify(this.getUsedFields(this.fieldGroups()));
  });

  constructor() {
    effect(() => {
      if (this.usedFieldConfigurations() === null) {
        return;
      }

      // initForm reads several signals that must not re-trigger this effect
      untracked(() => void this.initForm());
    });

    this.adminEntityService.entitySchemaUpdated
      .pipe(untilDestroyed(this))
      .subscribe(() => {
        this.availableFields.set([]); // force re-init of the label components that otherwise do not detect the change
        setTimeout(() => this.initForm());
      });

    // Emit configChange whenever fieldGroups diverges from the input-derived value,
    // i.e. after user-driven mutations but NOT when the config input resets the signal.
    effect(() => {
      const current = this.fieldGroups();
      const config = this.config();
      if (
        config &&
        JSON.stringify(current) !== JSON.stringify(config.fieldGroups)
      ) {
        this.configChange.emit({ ...config, fieldGroups: current });
      }
    });
  }

  /** counter to discard the results of outdated, concurrent initForm calls */
  private initFormVersion = 0;

  private async initForm() {
    const version = ++this.initFormVersion;
    const dummyEntity = new (this.entityType() as any)();

    const dummyForm = await this.entityFormService.createEntityForm(
      [...this.getUsedFields(this.fieldGroups()), ...this.availableFields()],
      dummyEntity,
      this.destroyRef,
    );
    if (version !== this.initFormVersion) {
      // a newer initForm has been started in the meantime, its result takes precedence
      return;
    }

    dummyForm.formGroup.disable();
    // set both together so that entity and form always match
    this.dummyEntity.set(dummyEntity);
    this.dummyForm.set(dummyForm);
  }

  private getUsedFields(fieldGroups: FieldGroup[]): ColumnConfig[] {
    return (fieldGroups ?? []).reduce((p, c) => p.concat(c.fields ?? []), []);
  }

  /** id of the toolbar's drop list, so that fields can be dragged out of the form again */
  readonly availableFieldsDropListId = computed(
    () => `availableFields-${this.uniqueAreaId()}`,
  );

  /**
   * Ids of all drop lists a field can be dragged into: every field group, the area creating a
   * new group, and the toolbar of fields not used in the form.
   */
  readonly connectedGroups = computed(() => {
    const config = this.config();
    const areaId = this.uniqueAreaId();

    if (!config) {
      return [`newGroupDropArea-${areaId}`, this.availableFieldsDropListId()];
    }

    return [
      ...this.fieldGroups().map(
        (_, groupIndex) => `${areaId}-group${groupIndex}`,
      ),
      `newGroupDropArea-${areaId}`,
      this.availableFieldsDropListId(),
    ];
  });

  /**
   * Load any fields from schema that are not already in the form, so that the user can drag them into the form.
   * @private
   */
  private computeAvailableFieldsList(): ColumnConfig[] {
    const entityType = this.entityType();
    if (!entityType) return [];

    const usedFields = this.getUsedFields(this.fieldGroups()).map((x) =>
      toFormFieldConfig(x),
    );
    const unusedFields = Array.from(entityType.schema.entries())
      .filter(([key]) => !usedFields.some((x) => x.id === key))
      .filter(([key, value]) => !value.isInternalField && value.label) // no technical, internal fields and must have label
      .sort(([aId, a], [bId, b]) => a.label.localeCompare(b.label))
      .map(([key]) => key);

    return [
      this.createNewFieldPlaceholder,
      this.createNewTextPlaceholder,
      ...unusedFields,
    ];
  }

  /**
   * Open the form to edit details of a single field's schema.
   *
   * @param field field to edit or { id: null } to create a new field
   * @returns the id of the field that was edited or created (which is newly defined in the dialog for new fields)
   */
  async openFieldConfig(field: ColumnConfig): Promise<EntitySchemaField> {
    const entitySchemaField = {
      ...this.entityType().schema.get(toFormFieldConfig(field).id),
    } as EntitySchemaField;
    if (field instanceof Object) {
      Object.assign(entitySchemaField, field);
    }

    // prefill with search filter text when creating new field
    if (
      (field === this.createNewFieldPlaceholder ||
        (typeof field === "object" && field.id === null)) &&
      this.searchFilter.value?.trim()
    ) {
      entitySchemaField.label = this.searchFilter.value.trim();
    }

    const dialogRef = this.matDialog.open(AdminEntityFieldComponent, {
      width: "99%",
      maxHeight: "90vh",
      data: {
        entitySchemaField: entitySchemaField,
        entityType: this.entityType(),
        overwriteLocally: this.updateEntitySchema() === false,
      } as AdminEntityFieldData,
    });

    const result = lastValueFrom(dialogRef.afterClosed());

    return result;
  }

  /**
   * Open the form to edit details of a single text's schema.
   *
   * @param textField text to edit or { id: null } to create a new text
   * @returns the id of the text that was edited or created (which is newly defined in the dialog for new fields)
   */
  async openTextConfig(textField: FormFieldConfig): Promise<FormFieldConfig> {
    const dialogRef = this.matDialog.open(
      AdminEditDescriptionOnlyFieldComponent,
      {
        data: textField,
      },
    );

    const result = await lastValueFrom(dialogRef.afterClosed());

    return result;
  }

  /**
   * Move a field that was dragged into a field group or back into the toolbar.
   *
   * Each drop list carries its identity (a field group's index or "available") as
   * `cdkDropListData` and each field carries itself as `cdkDragData`, so that the new
   * `fieldGroups` can be built immutably instead of splicing the arrays CDK hands over.
   */
  async drop(event: FieldDragDropEvent) {
    const target = event.container.data;
    if (target === "available" && this.isNewFieldPlaceholder(event.item.data)) {
      // the "create new" placeholders live in the toolbar already
      return;
    }

    const field = await this.resolveDroppedField(event.item.data);
    if (!field) {
      return;
    }

    this.fieldGroups.update((groups) =>
      moveFieldBetweenGroups(
        groups,
        field,
        event.previousContainer.data,
        event.previousIndex,
        target,
        event.currentIndex,
      ),
    );
  }

  /** Reorder the field groups (the form's columns). */
  dropFieldGroups(event: CdkDragDrop<unknown>) {
    this.fieldGroups.update((groups) => {
      const reordered = [...groups];
      moveItemInArray(reordered, event.previousIndex, event.currentIndex);
      return reordered;
    });
  }

  /**
   * Opens the configuration settings for a field.
   * If the field has an editComponent defined in the schema, it opens the text configuration.
   * Otherwise, it opens the field configuration.
   * @param field The field to open the configuration settings for.
   */
  async openConfigDetails(field: ColumnConfig) {
    let fieldIdToEdit = toFormFieldConfig(field).id;
    const configDetails = Object.assign(
      {},
      this.entityType().schema.get(fieldIdToEdit) ?? {},
      field,
    ) as FormFieldConfig;

    const updatedField =
      configDetails.viewComponent === "DisplayDescriptionOnly"
        ? await this.openTextConfig(configDetails)
        : await this.openFieldConfig(field);

    if (!updatedField) return;

    if (typeof updatedField === "string") {
      this.applySchemaOverride(updatedField, updatedField);
      await this.initForm();
      return;
    }

    if (
      this.updateEntitySchema() === false ||
      configDetails.viewComponent === "DisplayDescriptionOnly"
    ) {
      this.applySchemaOverride(
        updatedField.id,
        updatedField as FormFieldConfig,
      );
      await this.initForm();
    } else {
      // save to entity type's global schema
      this.adminEntityService.updateSchemaField(
        this.entityType(),
        updatedField.id,
        updatedField,
      );
      this.fieldGroups.update((g) => [...g]); // no structural change, but trigger configChange emit
    }
  }

  private applySchemaOverride(
    fieldId: string,
    updatedField: string | FormFieldConfig,
  ): void {
    this.fieldGroups.update((groups) =>
      groups.map((group) => {
        const index = group.fields.findIndex((f) =>
          f instanceof String
            ? f === fieldId
            : toFormFieldConfig(f).id === fieldId,
        );
        if (index === -1) return group;
        const newFields = [...group.fields];
        newFields[index] = updatedField;
        return { ...group, fields: newFields };
      }),
    );
  }

  private isNewFieldPlaceholder(field: ColumnConfig): boolean {
    return (
      field === this.createNewFieldPlaceholder ||
      field === this.createNewTextPlaceholder
    );
  }

  /**
   * The field a drop actually adds to the form: the "create new" placeholders open their config
   * dialog first (returning undefined if the user cancels), any other field is moved as it is.
   */
  private async resolveDroppedField(
    dragged: ColumnConfig,
  ): Promise<ColumnConfig | undefined> {
    if (dragged === this.createNewFieldPlaceholder) {
      return this.createNewField();
    }
    if (dragged === this.createNewTextPlaceholder) {
      return this.createNewTextBlock();
    }
    return dragged;
  }

  /**
   * Define a new field through its config dialog and add it to the entity type's schema.
   * @returns the new field's id, or undefined if the dialog was cancelled
   */
  private async createNewField(): Promise<string | undefined> {
    const newField = await this.openFieldConfig({ id: null });
    if (!newField?.id) {
      // without an id there is no field to add
      return undefined;
    }

    if (this.updateEntitySchema()) {
      this.adminEntityService.updateSchemaField(
        this.entityType(),
        newField.id,
        newField,
      );
    } else {
      // For local-only updates (e.g., public forms), manually update schema
      this.entityType().schema.set(newField.id, newField);
    }
    this.addFieldToPreview(newField.id);

    return newField.id;
  }

  /**
   * Define a new text block through its config dialog.
   * @returns the new text block, or undefined if the dialog was cancelled
   */
  private async createNewTextBlock(): Promise<FormFieldConfig | undefined> {
    const newTextField = await this.openTextConfig({ id: null });
    if (!newTextField) {
      return undefined;
    }
    this.addFieldToPreview(newTextField.id);

    return newTextField;
  }

  /** register a newly created field with the preview form, so that it can be rendered */
  private addFieldToPreview(fieldId: string) {
    const previewForm = this.dummyForm();
    if (!previewForm) {
      return;
    }
    previewForm.formGroup.addControl(fieldId, new FormControl());
    previewForm.formGroup.disable();
  }

  /** Move the dragged field into a new field group appended to the form. */
  async dropNewGroup(event: FieldDragDropEvent) {
    const field = await this.resolveDroppedField(event.item.data);
    if (!field) {
      return;
    }

    this.fieldGroups.update((groups) => {
      const withNewGroup: FieldGroup[] = [...groups, { fields: [] }];
      return moveFieldBetweenGroups(
        withNewGroup,
        field,
        event.previousContainer.data,
        event.previousIndex,
        withNewGroup.length - 1,
        0,
      );
    });
  }

  removeGroup(i: number) {
    this.fieldGroups.update((groups) => groups.filter((_, idx) => idx !== i));
  }

  hideField(field: ColumnConfig, group: FieldGroup) {
    this.fieldGroups.update((groups) =>
      groups.map((g) =>
        g === group ? { ...g, fields: g.fields.filter((f) => f !== field) } : g,
      ),
    );
  }

  updateGroupHeader(i: number, header: string) {
    this.fieldGroups.update((groups) =>
      groups.map((g, idx) => (idx === i ? { ...g, header } : g)),
    );
  }

  filteredFields = computed(() => {
    const searchTerm = this.searchFieldSignal()?.toLowerCase().trim() || "";
    const fields = this.availableFields();

    if (!searchTerm) {
      return fields;
    }

    return fields.filter((field) => {
      // always show the create new field and create new text placeholders
      if (
        field === this.createNewFieldPlaceholder ||
        field === this.createNewTextPlaceholder
      ) {
        return true;
      }

      const fieldConfig =
        this.entityFormService?.extendFormFieldConfig(
          field,
          this.entityType(),
        ) || toFormFieldConfig(field);

      const fieldId = fieldConfig.id?.toLowerCase() || "";
      const fieldLabel = fieldConfig.label?.toLowerCase() || "";

      return fieldId.includes(searchTerm) || fieldLabel.includes(searchTerm);
    });
  });

  clearSearch() {
    this.searchFilter.setValue("");
  }
}

/**
 * Remove empty (null / undefined) entries from the groups' field lists.
 *
 * These are not a valid `ColumnConfig` and only ever come from a malformed config, but every
 * consumer dereferences them (`toFormFieldConfig(field).id`) and would throw on the whole editor.
 * Dropping them here keeps the rest of the configuration editable so the admin can repair it.
 */
function dropEmptyFields(groups: FieldGroup[]): FieldGroup[] {
  return groups.map((group) =>
    group?.fields?.some((field) => field === null || field === undefined)
      ? {
          ...group,
          fields: group.fields.filter(
            (field) => field !== null && field !== undefined,
          ),
        }
      : group,
  );
}

/**
 * Move `field` from one drop target to another, returning new group objects for the groups that
 * changed (all others are kept as they are).
 *
 * A target of "available" is the toolbar of fields not used in the form: it holds no state of its
 * own (it is derived from the form's groups), so a field moved there is simply removed from the
 * form and a field moved from there is simply added.
 */
function moveFieldBetweenGroups(
  groups: FieldGroup[],
  field: ColumnConfig,
  from: FieldDropTarget,
  fromIndex: number,
  to: FieldDropTarget,
  toIndex: number,
): FieldGroup[] {
  return groups.map((group, index) => {
    if (index !== from && index !== to) {
      return group;
    }

    let fields = group.fields ?? [];
    if (index === from) {
      fields = [...fields.slice(0, fromIndex), ...fields.slice(fromIndex + 1)];
    }
    if (index === to) {
      // within the same group, `toIndex` refers to the list without the dragged field
      fields = [...fields.slice(0, toIndex), field, ...fields.slice(toIndex)];
    }
    return { ...group, fields };
  });
}
<div class="overall-container flex-row" [class.disabled]="isDisabled()">
  <!-- FORM PREVIEW -->
  <div
    cdkDropList
    (cdkDropListDropped)="dropFieldGroups($event)"
    cdkDropListOrientation="mixed"
    class="flex-grow padding-right-regular drop-list"
    [class.admin-grid-layout]="!fieldsOnlyMode()"
    [class.fieldsOnlyMode]="fieldsOnlyMode()"
  >
    <!-- FIELD GROUPS -->
    @for (group of fieldGroups(); track i; let i = $index) {
      <div class="entity-form-cell admin-form-column section-container" cdkDrag>
        @if (!fieldsOnlyMode()) {
          <div class="flex-row align-center">
            <fa-icon
              icon="grip-vertical"
              size="xl"
              class="drag-handle"
            ></fa-icon>
            <!-- GROUP HEADER -->
            <app-admin-section-header
              class="flex-grow"
              [title]="group.header"
              (titleChange)="updateGroupHeader(i, $event)"
              (remove)="removeGroup(i)"
            ></app-admin-section-header>
          </div>
        }
        <div
          cdkDropList
          [id]="uniqueAreaId() + '-group' + i"
          [cdkDropListData]="i"
          (cdkDropListDropped)="drop($event)"
          [cdkDropListConnectedTo]="connectedGroups()"
          [cdkDropListDisabled]="isDisabled()"
          cdkDropListOrientation="mixed"
          class="fields-group-list drop-list"
        >
          <!-- FIELD [start] -->
          @for (field of group.fields; track field) {
            <!--
              No cdkDragBoundary here: it clamps the pointer to
              `boundary.right - (previewWidth - grabOffset)`, which for a field-wide preview
              grabbed at its handle stops short of the toolbar, making it impossible to drag
              a field out of the form again.
            -->
            <div
              class="admin-form-field flex-row align-center"
              cdkDrag
              [cdkDragData]="field"
            >
              <fa-icon
                icon="grip-vertical"
                size="xl"
                class="drag-handle"
              ></fa-icon>
              <div class="field-hover-buttons flex-row align-center gap-small">
                <button
                  class="field-edit-button"
                  mat-stroked-button
                  color="accent"
                  (click)="openConfigDetails(field)"
                  i18n="Button label"
                >
                  Edit Field
                </button>
                <button
                  mat-icon-button
                  color="black"
                  (click)="hideField(field, group)"
                  i18n-aria-label
                  aria-label="Hide Field"
                  matTooltip="remove (i.e. hide) this field from the form"
                  i18n-matTooltip
                >
                  <fa-icon icon="times"></fa-icon>
                </button>
              </div>
              <div class="dummy-form-field">
                <app-entity-field-edit
                  [field]="field"
                  [entity]="dummyEntity()"
                  [form]="dummyForm()"
                ></app-entity-field-edit>
              </div>
            </div>
          }
          <!-- FIELD [end]-->
        </div>
      </div>
    }

    <!-- DROP AREA: NEW FIELD GROUP -->
    @if (!fieldsOnlyMode()) {
      <div class="entity-form-cell admin-form-column">
        <div
          [id]="'newGroupDropArea-' + uniqueAreaId()"
          cdkDropList
          [cdkDropListConnectedTo]="connectedGroups()"
          (cdkDropListDropped)="dropNewGroup($event)"
          class="fields-group-list drop-list drop-area-hint"
        >
          <span i18n="Header of drag and drop box"
            >drop here to create new field group</span
          >
          <fa-icon
            icon="question-circle"
            matTooltip="You can group fields so that they will always appear next to each other while the layout is automatically adjusted to different screen sizes. Such field groups can also have a title displayed to users."
            i18n-matTooltip
          ></fa-icon>
        </div>
      </div>
    }
  </div>

  <!-- TOOLBAR -->
  <mat-card class="toolbar">
    <mat-card-content>
      <div
        cdkDropList
        [id]="availableFieldsDropListId()"
        (cdkDropListDropped)="drop($event)"
        [cdkDropListData]="availableFieldsTarget"
        [cdkDropListConnectedTo]="connectedGroups()"
        [cdkDropListDisabled]="isDisabled()"
        class="drop-list"
      >
        <div class="drop-area-hint">
          <em i18n="title for drag and drop section">hidden fields</em><br />
          <span i18n="subtitle for drag and drop section">
            drag & drop to / from here
          </span>
          <fa-icon
            icon="question-circle"
            matTooltip="You can remove fields from this form without deleting them completely. The fields here will not be visible or editable for users but you can display them in other views or keep them archived to not lose previously collected data. Drag a field back into the form preview to display it."
            i18n-matTooltip
          ></fa-icon>
        </div>

        <div class="margin-top-regular">
          <mat-form-field appearance="fill" class="full-width">
            <input
              matInput
              [formControl]="searchFilter"
              placeholder="Search fields"
              i18n-placeholder
            />

            <button
              matIconSuffix
              mat-icon-button
              class="muted-button"
              (click)="clearSearch()"
              matTooltip="Clear search"
              i18n-matTooltip="Clear search tooltip"
              aria-label="Clear search"
              i18n-aria-label="Clear search aria label"
            >
              <fa-icon icon="times"></fa-icon>
            </button>
          </mat-form-field>
        </div>

        @for (field of filteredFields(); track field) {
          <div
            class="admin-form-field flex-row align-center"
            [class.admin-form-field-new]="
              field === createNewFieldPlaceholder ||
              field === createNewTextPlaceholder
            "
            cdkDrag
            [cdkDragData]="field"
            cdkDragBoundary="mat-drawer-container"
          >
            <fa-icon
              icon="grip-vertical"
              size="xl"
              class="drag-handle"
            ></fa-icon>
            <div>
              <app-entity-field-label
                [field]="field"
                [entityType]="entityType()"
              ></app-entity-field-label>
              @if (
                field !== createNewFieldPlaceholder &&
                field !== createNewTextPlaceholder
              ) {
                <button
                  mat-stroked-button
                  color="accent"
                  class="field-hover-buttons field-edit-button"
                  (click)="openConfigDetails(field)"
                >
                  <span i18n>Edit</span>
                </button>
              }
            </div>
          </div>
        }
      </div>
    </mat-card-content>
  </mat-card>
</div>

./admin-entity-form.component.scss

@use "variables/colors";
@use "variables/sizes";
@use "mixins/grid-layout";
@use "@angular/material/core/style/elevation" as mat-elevation;

$toolbar-width: 300px;
.toolbar {
  width: $toolbar-width;
  padding: sizes.$small;
  margin-right: -(sizes.$small);
  margin-bottom: -(sizes.$small);

  @include mat-elevation.elevation(4);
  border-bottom-left-radius: 0;
  border-top-right-radius: 0;
  background-color: transparent;
}

.admin-grid-layout {
  @include grid-layout.adaptive(
    $min-block-width: calc(
        #{sizes.$form-group-min-width} + 28px + 2 * 2 *#{sizes.$small}
      ),
    $max-screen-width: 414px
  );
}

.fields-group-list {
  border: dashed 1px #ccc;
  border-radius: 4px;
  overflow: hidden;
  display: block;
  padding: 0 sizes.$small;
}
.drop-list {
  min-height: 60px;
  height: 99%;
}

.admin-form-field {
  padding: sizes.$small;
  margin: sizes.$small auto;
  border: dotted 1px colors.$accent;
  border-radius: sizes.$x-small;
  position: relative;
  overflow: hidden;

  /* draggable item must not be wider than toolbar, otherwise it cannot be dropped there due to cdkDragBoundary */
  max-width: $toolbar-width;
  min-height: 3.5em;
  min-width: 250px;
}
.admin-form-field:hover {
  background-color: colors.$grey-transparent;
}

.admin-form-field-new,
.admin-form-field-new:hover {
  border-color: green;
  background-color: rgba(0, 255, 0, 0.05);
  font-style: italic;
  cursor: move;
}

.drag-handle {
  color: colors.$accent;
  cursor: move;
  min-width: 2em;
  text-align: center;
}

.field-hover-buttons {
  visibility: hidden;
  z-index: 10;

  position: absolute;
  /* center within parent: */
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  margin: auto;
  width: fit-content;

  align-self: center;
}
.admin-form-field:hover .field-hover-buttons {
  visibility: visible;
}
.field-edit-button {
  background: white !important;
  padding: 1.5em;
}

.dummy-form-field {
  width: 100%;
}
.dummy-form-field ::ng-deep input,
.dummy-form-field ::ng-deep button {
  pointer-events: none;
}
.dummy-form-field ::ng-deep mat-form-field {
  width: 100%;
}
.dummy-form-field ::ng-deep app-help-button {
  opacity: 50%;
}

.drop-area-hint {
  text-align: center;
  padding: sizes.$small;
  color: colors.$hint-text;
}

.admin-form-column {
  border: dashed 1px #ccc;
  padding: sizes.$small;
}

.cdk-drag-preview {
  box-sizing: border-box;
  border-radius: 4px;
  box-shadow:
    0 5px 5px -3px rgba(0, 0, 0, 0.2),
    0 8px 10px 1px rgba(0, 0, 0, 0.14),
    0 3px 14px 2px rgba(0, 0, 0, 0.12);
}

.cdk-drag-placeholder {
  opacity: 0.4;
  border-color: green;
}

.cdk-drag-animating {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

.drop-list.cdk-drop-list-dragging .admin-form-field:not(.cdk-drag-placeholder) {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

.disabled {
  pointer-events: none;
  opacity: 0.5;
}

// Horizontal layout only when fieldsOnlyMode is true
.fieldsOnlyMode {
  .fields-group-list {
    display: flex;
    flex-wrap: wrap;
    gap: sizes.$small;
    padding: sizes.$small;
  }

  .fields-group-list .admin-form-field {
    margin: 0;
    flex: 0 1 auto;
  }
}

/* make the clear search button a bit different from the "delete section" x buttons */
.muted-button {
  color: colors.$muted;
}

../../building-blocks/admin-section-header/admin-section-header.component.scss

.section-container:has(.group-remove-button:hover) {
  border-color: rgb(255, 0, 0);
  background-color: rgba(255, 0, 0, 0.1);
}

.no-field-margin {
  margin-bottom: .5em;
}

../../../common-components/entity-form/entity-form/entity-form.component.scss

@use "mixins/grid-layout";
@use "variables/sizes";

.grid-layout {
  @include grid-layout.adaptive(
    $min-block-width: sizes.$form-group-min-width,
    $max-screen-width: 414px
  );
}

.entity-form-cell {
  display: flex;
  flex-direction: column;

  /* set the width of each form field to 100% in every form component that is a descendent
     of the columns-wrapper class */
  mat-form-field {
    width: 100%;
    max-width: 864px;
  }

  /* We align the photo (and only tht photo) to the center of the cell if there is one.
     This looks better on desktop and mobile compared to an alignment to the start of the cell
     which is the default for all other elements */
  > app-edit-photo {
    align-self: center;
  }
}

.full-width mat-form-field {
  max-width: none;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""