src/app/features/public-form/public-form.component.ts

Implements

OnInit

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Methods

Async reset
reset()
Returns : any
Async submit
submit()
Returns : any

Properties

entityFormEntries
Type : unknown
Default value : signal< Array< PublicFormEntry & { config: PublicFormEntityFormConfig; entityType: EntityConstructor<Entity>; fieldGroups: FieldGroup[]; } > >([])
error
Type : unknown
Default value : signal<"not_found" | "no_permissions" | undefined>(undefined)
formConfig
Type : unknown
Default value : signal<PublicFormConfig | undefined>(undefined)
invalidFieldNames
Type : unknown
Default value : signal<string[]>([])
validationError
Type : unknown
Default value : signal(false)
import {
  Component,
  DestroyRef,
  inject,
  OnInit,
  ChangeDetectionStrategy,
  signal,
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { EntityRegistry } from "../../core/entity/database-entity.decorator";
import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service";
import {
  PublicFormConfig,
  PublicFormEntityFormConfig,
} from "./public-form-config";
import { Entity, EntityConstructor } from "../../core/entity/model/entity";
import { EntityFormService } from "../../core/common-components/entity-form/entity-form.service";
import { EntityFormComponent } from "../../core/common-components/entity-form/entity-form/entity-form.component";
import { MatButtonModule } from "@angular/material/button";
import { ConfigService } from "../../core/config/config.service";
import { MatCardModule } from "@angular/material/card";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { FieldGroup } from "../../core/entity-details/form/field-group";
import { InvalidFormFieldError } from "../../core/common-components/entity-form/invalid-form-field.error";
import {
  FormFieldConfig,
  toFormFieldConfig,
} from "app/core/common-components/entity-form/FormConfig";
import { DefaultValueConfig } from "../../core/default-values/default-value-config";
import { DisplayImgComponent } from "../file/display-img/display-img.component";
import { EntityAbility } from "app/core/permissions/ability/entity-ability";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MarkdownPageModule } from "../markdown-page/markdown-page.module";
import { DatabaseResolverService } from "../../core/database/database-resolver.service";
import {
  PublicFormEntry,
  PublicFormLinkingService,
} from "./public-form-linking.service";
import { UpdateMetadata } from "../../core/entity/model/update-metadata";

@UntilDestroy()
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-public-form",
  templateUrl: "./public-form.component.html",
  styleUrls: ["./public-form.component.scss"],
  imports: [
    EntityFormComponent,
    MatButtonModule,
    MatCardModule,
    DisplayImgComponent,
    FontAwesomeModule,
    MarkdownPageModule,
  ],
})
export class PublicFormComponent<E extends Entity> implements OnInit {
  private databaseResolver = inject(DatabaseResolverService);
  private route = inject(ActivatedRoute);
  private entities = inject(EntityRegistry);
  private entityMapper = inject(EntityMapperService);
  private entityFormService = inject(EntityFormService);
  private configService = inject(ConfigService);
  private readonly publicFormLinkingService = inject(PublicFormLinkingService);

  private ability = inject(EntityAbility);
  private router = inject(Router);
  private readonly destroyRef = inject(DestroyRef);

  formConfig = signal<PublicFormConfig | undefined>(undefined);
  entityFormEntries = signal<
    Array<
      PublicFormEntry & {
        config: PublicFormEntityFormConfig;
        entityType: EntityConstructor<Entity>;
        fieldGroups: FieldGroup[];
      }
    >
  >([]);
  error = signal<"not_found" | "no_permissions" | undefined>(undefined);
  validationError = signal(false);
  invalidFieldNames = signal<string[]>([]);

  ngOnInit() {
    this.databaseResolver.initDatabasesForAnonymous();

    // wait for config to be initialized
    this.configService.configUpdates
      .pipe(untilDestroyed(this))
      .subscribe(() => this.loadFormConfig());
  }

  async submit() {
    this.clearValidationState();
    const formMetadataBy = `PublicForm:${this.formConfig().getId(true)}`;
    this.publicFormLinkingService.applyLinkedFromForm(this.entityFormEntries());
    if (this.hasInvalidForms()) {
      this.markAllFormsAsTouched();
      // Collect invalid field names for summary message
      this.validationError.set(true);
      this.invalidFieldNames.set(this.collectInvalidFieldNames());
      return;
    }
    try {
      for (const entry of this.entityFormEntries()) {
        // Skip entries that were already saved in a previous submit attempt.
        // After a successful save the entity has a `_rev` (isNew === false).
        // Re-saving it would be treated as an "update" — which the "public"
        // role is not permitted to do (it only has "create") — and would also
        // risk creating a duplicate. This makes a retry after a partially
        // successful submission resume cleanly instead of failing.
        if (entry.entity && !entry.entity.isNew) {
          continue;
        }
        entry.entity.created = new UpdateMetadata(formMetadataBy);
        await this.entityFormService.saveChanges(entry.form, entry.entity);
      }
      this.router.navigate(["/public-form/submission-success"], {
        queryParams: {
          showSubmitAnotherButton:
            this.formConfig()?.showSubmitAnotherButton !== false,
        },
      });
    } catch (e) {
      if (e instanceof InvalidFormFieldError) {
        this.markAllFormsAsTouched();
        // Collect invalid field names for summary message
        this.validationError.set(true);
        this.invalidFieldNames.set(this.collectInvalidFieldNames());
        return;
      }
      throw e;
    }
  }

  async reset() {
    this.clearValidationState();
    await this.initForms();
  }

  private clearValidationState() {
    this.validationError.set(false);
    this.invalidFieldNames.set([]);
  }

  private hasInvalidForms(): boolean {
    return this.entityFormEntries().some(
      (entry) => entry.form?.formGroup?.invalid ?? true,
    );
  }

  private markAllFormsAsTouched() {
    this.entityFormEntries().forEach((entry) => {
      entry.form?.formGroup?.markAllAsTouched();
    });
  }

  /**
   * Collects the labels of all invalid fields across all form entries.
   * This provides a summary for forms with many fields where the invalid field might be off-screen.
   */
  private collectInvalidFieldNames(): string[] {
    const invalidNames: string[] = [];

    for (const entry of this.entityFormEntries()) {
      if (entry.form?.formGroup?.invalid) {
        const formGroup = entry.form.formGroup;
        const fieldConfigs = entry.form.fieldConfigs;

        for (const control of Object.keys(formGroup.controls)) {
          const formControl = formGroup.get(control);
          if (formControl?.invalid) {
            // Find the field config to get the label
            const fieldConfig = fieldConfigs.find((f) => f.id === control);
            invalidNames.push(fieldConfig?.label || control);
          }
        }
      }
    }

    return [...new Set(invalidNames)];
  }

  private async loadFormConfig() {
    const id = this.route.snapshot.paramMap.get("id");

    const publicForms = (await this.entityMapper.loadType(PublicFormConfig))
      .map((formConfig) => this.configService.applyMigrations(formConfig))
      .map((formConfig) => migratePublicFormConfig(formConfig));

    const formConfig = publicForms.find(
      (form: PublicFormConfig) => form.route === id || form.getId(true) === id,
    );
    if (!formConfig) {
      this.error.set("not_found");
      return;
    }
    this.formConfig.set(formConfig);

    const entries = this.getEntityFormEntriesConfig().map((config) => {
      const entityType = this.entities.get(
        config.entity,
      ) as EntityConstructor<Entity>;
      return {
        config,
        entityType,
        entity: null,
        fieldGroups: config.columns,
        form: null,
      };
    });

    if (
      entries.some((entry) => this.ability.cannot("create", entry.entityType))
    ) {
      this.error.set("no_permissions");
      return;
    }

    entries.forEach((entry) => {
      this.handlePrefilledFields(entry.config, entry.fieldGroups);
    });
    this.publicFormLinkingService.handleUrlParameterLinking(
      entries,
      this.route.snapshot?.queryParams || {},
      this.applyPrefill.bind(this),
    );

    await this.initForms(entries);
  }

  private handlePrefilledFields(
    formConfig: PublicFormEntityFormConfig,
    fieldGroups: FieldGroup[],
  ) {
    if (formConfig.prefilled && typeof formConfig.prefilled === "object") {
      for (const [fieldId, defaultValue] of Object.entries(
        formConfig.prefilled,
      )) {
        this.applyPrefill(
          fieldGroups,
          fieldId,
          defaultValue as DefaultValueConfig,
        );
      }
    }
  }

  /**
   * - If field already exists in existing column then only set defaultValue.
   * - If field not found in any existing column then  add to last column hidden by default.
   */
  private applyPrefill(
    fieldGroups: FieldGroup[],
    fieldId: string,
    defaultValue: DefaultValueConfig,
    hideFromForm?: boolean,
  ) {
    if (!fieldId) return;
    const findField = (field) =>
      field === fieldId || (typeof field === "object" && field.id === fieldId);
    const fieldGroup = fieldGroups.find((group) =>
      group.fields.some(findField),
    );

    if (fieldGroup) {
      const fieldIndex = fieldGroup.fields.findIndex(findField);
      const existingField: FormFieldConfig = toFormFieldConfig(
        fieldGroup.fields[fieldIndex],
      );
      existingField.defaultValue = defaultValue;
      fieldGroup.fields[fieldIndex] = existingField;
    } else {
      const lastColumn = fieldGroups.at(-1);
      lastColumn?.fields.push({
        id: fieldId,
        defaultValue,
        hideFromForm: hideFromForm ?? true,
      });
    }
  }

  private async initForms(entries = this.entityFormEntries()) {
    for (const entry of entries) {
      entry.entity = new entry.entityType();
      entry.form = await this.entityFormService.createEntityForm(
        [].concat(...entry.fieldGroups.map((group) => group.fields)),
        entry.entity,
        this.destroyRef,
      );

      entry.form.formGroup.valueChanges
        .pipe(untilDestroyed(this))
        .subscribe(() => {
          if (!this.validationError()) {
            return;
          }

          if (this.hasInvalidForms()) {
            this.invalidFieldNames.set(this.collectInvalidFieldNames());
            return;
          }

          this.clearValidationState();
        });
    }
    this.entityFormEntries.set([...entries]);
  }

  private getEntityFormEntriesConfig(): PublicFormEntityFormConfig[] {
    const config = this.formConfig()!;
    if (Array.isArray(config.forms) && config.forms.length) {
      return config.forms.map((form) =>
        this.applyDefaultPublicFormFieldDisplaySettings(form),
      );
    }

    return [
      this.applyDefaultPublicFormFieldDisplaySettings({
        entity: config.entity,
        columns: config.columns,
        prefilled: config.prefilled,
        linkedEntities: config.linkedEntities,
      }),
    ];
  }

  private applyDefaultPublicFormFieldDisplaySettings(
    formConfig: PublicFormEntityFormConfig,
  ): PublicFormEntityFormConfig {
    formConfig.columns = (formConfig.columns ?? []).map((group) => {
      group.fields = group.fields.map((field) => {
        const fieldConfig = toFormFieldConfig(field);

        return {
          ...fieldConfig,
          displayFullLengthLabel: fieldConfig.displayFullLengthLabel ?? true,
          displayFullLengthOptionLabel:
            fieldConfig.displayFullLengthOptionLabel ?? true,
        };
      });
      return group;
    });

    return formConfig;
  }
}

export function migratePublicFormConfig(
  formConfig: PublicFormConfig,
): PublicFormConfig {
  const migrateColumns = (columns?: Array<FieldGroup | string[]>) => {
    if (!columns) return columns as FieldGroup[];
    return columns.map((column) => {
      return Array.isArray(column)
        ? { fields: column, header: null }
        : { fields: column.fields || [], header: column?.header || null };
    });
  };

  const migrateLinkedEntities = (linkedEntities: any): string[] | undefined => {
    if (!linkedEntities) return undefined;
    if (Array.isArray(linkedEntities) && linkedEntities.length === 0) {
      return undefined;
    }
    if (
      Array.isArray(linkedEntities) &&
      linkedEntities.every((item) => typeof item === "string")
    ) {
      return linkedEntities;
    }
    // migrate old format (FormFieldConfig[]) to new format (string[])
    if (
      Array.isArray(linkedEntities) &&
      linkedEntities.some((item) => typeof item === "object" && item?.id)
    ) {
      return linkedEntities.map((item) => item.id).filter((id) => id);
    }
    return undefined;
  };

  formConfig.columns = migrateColumns(formConfig.columns) as FieldGroup[];
  formConfig.linkedEntities = migrateLinkedEntities(formConfig.linkedEntities);

  if (Array.isArray(formConfig.forms)) {
    formConfig.forms = formConfig.forms.map((form) => ({
      ...form,
      columns: migrateColumns(form.columns) as FieldGroup[],
      linkedEntities: migrateLinkedEntities(form.linkedEntities),
    }));
  }
  return formConfig;
}
@if (error()) {
  <div class="flex-column place-center error-container">
    <fa-icon
      icon="triangle-exclamation"
      class="margin-bottom-regular"
      size="6x"
    ></fa-icon>
    @switch (error()) {
      @case ("not_found") {
        <h1 i18n>Public Form not found</h1>
        <p i18n>
          The form you are looking for is either unavailable or doesn't exist.
          Please check the link and try again.
        </p>
      }
      @case ("no_permissions") {
        <h1 i18n>No access to public form "{{ formConfig()?.title }}"</h1>
        <p i18n>
          Unfortunately, you do not have the required permissions to submit data
          through this form. Please contact the person who shared this link with
          you. In case you have a user account, please make sure you are logged
          in.
        </p>
      }
    }
  </div>
} @else {
  <mat-card>
    <mat-card-header>
      <div class="card-container">
        <mat-card-title>{{ formConfig()?.title }}</mat-card-title>
        <div class="public-form-container">
          @if (formConfig()?.logo) {
            <app-display-img
              [entity]="formConfig()"
              imgProperty="logo"
              class="public-form-image"
            ></app-display-img>
          }
        </div>
        @if (formConfig()?.description) {
          <!-- needs the @if wrapper because <markdown> doesn't seem to do good change detection -->
          <mat-card-subtitle>
            <markdown>{{ formConfig()?.description }}</markdown>
          </mat-card-subtitle>
        }
      </div>
    </mat-card-header>

    @if (formConfig()) {
      <mat-card-content>
        @for (entry of entityFormEntries(); track $index) {
          <app-entity-form
            [entity]="entry.entity"
            [fieldGroups]="entry.fieldGroups"
            [form]="entry.form"
            [gridLayout]="false"
          ></app-entity-form>
        }
      </mat-card-content>

      <mat-card-footer>
        @if (validationError()) {
          <div
            class="flex-column align-start margin-bottom-regular invalid-fields-container"
            role="alert"
          >
            <div class="color-error" i18n>
              Some fields are invalid, please check the form and submit again:
            </div>

            @if (invalidFieldNames.length > 0) {
              <ul class="color-error invalid-fields-list">
                @for (fieldName of invalidFieldNames(); track fieldName) {
                  <li>{{ fieldName }}</li>
                }
              </ul>
            }
          </div>
        }
        <button
          mat-raised-button
          (click)="submit()"
          color="accent"
          i18n="Submit form button"
        >
          Submit Form
        </button>
        <button mat-stroked-button (click)="reset()" i18n="Reset form button">
          Reset
        </button>
      </mat-card-footer>
    } @else {
      <mat-card-content>
        <p i18n>Loading form ...</p>
      </mat-card-content>
    }
  </mat-card>
}

./public-form.component.scss

@use "variables/colors";

:host {
  max-width: 700px;
  margin: auto;
}

button {
  width: 95%;
  height: 50px !important;
  font-size: large;
  margin-left: 2.5%;
  margin-bottom: 10px;
}

.public-form-container {
  display: flex;
  justify-content: center;
}

.card-container {
  width: 100%;
}

.public-form-image {
  max-width: 100%;
  height: 100px;
  object-fit: contain;
}

.error-container {
  display: flex;
  justify-content: center;
  min-height: 60vh;
  text-align: center;
}

.invalid-fields-container {
  padding: 12px 16px;
  background-color: rgba(colors.$error, 0.08);
  border-radius: 4px;
  border-left: 4px solid colors.$error;
}

.invalid-fields-list {
  list-style-type: disc;
  margin: 8px 0 0 20px;
  padding: 0;

  li {
    margin: 4px 0;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""