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 };
});
}
@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;
}
.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;
}
@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;
}