Skip to content

Commit

Permalink
feat(public forms): admin UI for creating and editing public forms (#…
Browse files Browse the repository at this point in the history
…2682)

closes #2271

---------

Co-authored-by: Sebastian <[email protected]>
  • Loading branch information
Abhinegi2 and sleidig authored Dec 11, 2024
1 parent 32ed689 commit 6464c5b
Show file tree
Hide file tree
Showing 13 changed files with 350 additions and 10 deletions.
2 changes: 2 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import { AdminModule } from "./core/admin/admin.module";
import { Logging } from "./core/logging/logging.service";
import { APP_INITIALIZER_DEMO_DATA } from "./core/demo-data/demo-data.app-initializer";
import { TemplateExportModule } from "./features/template-export/template-export.module";
import { PublicFormModule } from "./features/public-form/public-form.module";

/**
* Main entry point of the application.
Expand Down Expand Up @@ -132,6 +133,7 @@ import { TemplateExportModule } from "./features/template-export/template-export
TodosModule,
AdminModule,
TemplateExportModule,
PublicFormModule,
// top level component
UiComponent,
// Global Angular Material modules
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<app-admin-entity-form
*ngIf="componentConfig.component === 'Form'; else otherComponent"
[config]="componentConfig.config"
(configChange)="componentConfig.config = $event"
[entityType]="entityConstructor"
>
</app-admin-entity-form>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
<div cdkDropListGroup class="overall-container flex-row">
<div
cdkDropListGroup
class="overall-container flex-row"
[class.disabled]="isDisabled"
>
<!-- FORM PREVIEW -->
<div class="flex-grow admin-grid-layout padding-right-regular">
<!-- FIELD GROUPS -->
Expand All @@ -16,6 +20,7 @@
cdkDropList
[cdkDropListData]="group.fields"
(cdkDropListDropped)="drop($event)"
[cdkDropListDisabled]="isDisabled"
class="fields-group-list drop-list"
>
<!-- FIELD [start] -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,8 @@ $toolbar-width: 300px;
.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;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import {
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
} from "@angular/core";
import { Entity, EntityConstructor } from "../../../entity/model/entity";
import {
EntityForm,
Expand Down Expand Up @@ -59,7 +66,29 @@ import { FieldGroup } from "app/core/entity-details/form/field-group";
export class AdminEntityFormComponent implements OnChanges {
@Input() entityType: EntityConstructor;

@Input() config: FormConfig;
@Input() set config(value: FormConfig) {
// assign default value and make a deep copy to avoid side effects
if (!value) {
value = { fieldGroups: [] };
}
value = JSON.parse(JSON.stringify(value));
if (!value.fieldGroups) {
value.fieldGroups = [];
}

this._config = value;
}
get config(): FormConfig {
return this._config;
}
private _config: FormConfig;

@Output() configChange = new EventEmitter<FormConfig>();

/**
* Whether the UI is readonly, not allowing the user to drag or edit things.
*/
@Input() isDisabled: boolean = false;

dummyEntity: Entity;
dummyForm: EntityForm<any>;
Expand Down Expand Up @@ -89,7 +118,7 @@ export class AdminEntityFormComponent implements OnChanges {
}

async ngOnChanges(changes: SimpleChanges): Promise<void> {
if (changes.config) {
if (Object.hasOwn(changes, "config")) {
await this.initForm();
}
}
Expand Down Expand Up @@ -134,6 +163,10 @@ export class AdminEntityFormComponent implements OnChanges {
];
}

private emitUpdatedConfig() {
this.configChange.emit(this.config);
}

/**
* Open the form to edit details of a single field's schema.
*
Expand Down Expand Up @@ -204,6 +237,8 @@ export class AdminEntityFormComponent implements OnChanges {
// ensure available fields have consistent order
this.initAvailableFields();
}

this.emitUpdatedConfig();
}

/**
Expand Down Expand Up @@ -253,6 +288,8 @@ export class AdminEntityFormComponent implements OnChanges {

// the schema update has added the new field to the available fields already, remove it from there
this.availableFields.splice(this.availableFields.indexOf(newFieldId), 1);

this.emitUpdatedConfig();
}
/**
* drop handler specifically for the "create new Text field" item
Expand All @@ -278,6 +315,8 @@ export class AdminEntityFormComponent implements OnChanges {

// the schema update has added the new Text field to the available fields already, remove it from there
this.availableFields.splice(this.availableFields.indexOf(newTextField), 1);

this.emitUpdatedConfig();
}

dropNewGroup(event: CdkDragDrop<any, any>) {
Expand All @@ -290,11 +329,15 @@ export class AdminEntityFormComponent implements OnChanges {
removeGroup(i: number) {
const [removedFieldGroup] = this.config.fieldGroups.splice(i, 1);
this.initAvailableFields();

this.emitUpdatedConfig();
}

hideField(field: ColumnConfig, group: FieldGroup) {
const fieldIndex = group.fields.indexOf(field);
group.fields.splice(fieldIndex, 1);
this.initAvailableFields();

this.emitUpdatedConfig();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import moment from "moment";
import { EntityFieldEditComponent } from "../../entity-field-edit/entity-field-edit.component";
import { FieldGroup } from "../../../entity-details/form/field-group";
import { EntityAbility } from "../../../permissions/ability/entity-ability";
import { FormsModule } from "@angular/forms";

/**
* A general purpose form component for displaying and editing entities.
Expand All @@ -35,7 +36,7 @@ import { EntityAbility } from "../../../permissions/ability/entity-ability";
// Use no encapsulation because we want to change the value of children (the mat-form-fields that are
// dynamically created)
encapsulation: ViewEncapsulation.None,
imports: [NgForOf, NgIf, NgClass, EntityFieldEditComponent],
imports: [NgForOf, NgIf, NgClass, EntityFieldEditComponent, FormsModule],
standalone: true,
})
export class EntityFormComponent<T extends Entity = Entity>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<div class="public-form-detail-hint-banner" i18n>
You can edit how users will see the details of this public form. To edit
fields, click on the Edit button, make your changes, and then save. These
changes will reflect on the public form.
<br />
Drag and drop fields and sections in this preview of a profile view. The
editor below closely resembles how the form will look for users later. Forms
show all fields below each other not in multiple columns, however.
<br />
</div>
<app-admin-entity-form
[config]="publicFormConfig"
(configChange)="updateValue($event)"
[entityType]="entityConstructor"
[isDisabled]="formControl.disabled"
>
</app-admin-entity-form>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@use "variables/sizes";

.public-form-detail-hint-banner{
margin-bottom: sizes.$regular;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { EditPublicFormColumnsComponent } from "./edit-public-form-columns.component";
import { EntityRegistry } from "app/core/entity/database-entity.decorator";
import { Entity } from "app/core/entity/model/entity";
import { FormControl } from "@angular/forms";
import { Database } from "app/core/database/database";
import { EntityFormService } from "app/core/common-components/entity-form/entity-form.service";
import { TestEntity } from "app/utils/test-utils/TestEntity";
import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";

describe("EditPublicFormColumnsComponent", () => {
let component: EditPublicFormColumnsComponent;
let fixture: ComponentFixture<EditPublicFormColumnsComponent>;
let mockEntityRegistry: Partial<EntityRegistry>;
let mockEntityFormService: jasmine.SpyObj<EntityFormService>;

const testColumns = [
{
fields: ["name", "phone"],
},
];
beforeEach(() => {
let mockDatabase: jasmine.SpyObj<Database>;
mockEntityFormService = jasmine.createSpyObj("EntityFormService", [
"createEntityForm",
"extendFormFieldConfig",
]);
mockEntityRegistry = {
get: jasmine.createSpy("get").and.returnValue(Entity),
};

TestBed.configureTestingModule({
declarations: [],
imports: [
EditPublicFormColumnsComponent,
FontAwesomeTestingModule,
NoopAnimationsModule,
],
providers: [
{ provide: Database, useValue: mockDatabase },
{ provide: EntityRegistry, useValue: mockEntityRegistry },
{ provide: EntityFormService, useValue: mockEntityFormService },
],
}).compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(EditPublicFormColumnsComponent);
component = fixture.componentInstance;
component.entity = new TestEntity();
component.entity["columns"] = testColumns;
component.formControl = new FormControl();
fixture.detectChanges();
});

it("should create the component", () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Component, inject, OnInit } from "@angular/core";
import { EditComponent } from "app/core/entity/default-datatype/edit-component";
import { DynamicComponent } from "app/core/config/dynamic-components/dynamic-component.decorator";

import { EntityConstructor } from "app/core/entity/model/entity";
import { EntityRegistry } from "app/core/entity/database-entity.decorator";
import { AdminEntityFormComponent } from "app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component";
import { FormConfig } from "app/core/entity-details/form/form.component";
import { FieldGroup } from "app/core/entity-details/form/field-group";
@Component({
selector: "app-edit-public-form-columns",
standalone: true,
imports: [AdminEntityFormComponent],
templateUrl: "./edit-public-form-columns.component.html",
styleUrl: "./edit-public-form-columns.component.scss",
})
@DynamicComponent("EditPublicFormColumns")
export class EditPublicFormColumnsComponent
extends EditComponent<FieldGroup[]>
implements OnInit
{
entityConstructor: EntityConstructor;
publicFormConfig: FormConfig;

private entities = inject(EntityRegistry);

override ngOnInit(): void {
if (this.entity) {
this.entityConstructor = this.entities.get(this.entity["entity"]);

this.publicFormConfig = { fieldGroups: this.formControl.getRawValue() };
}
}

updateValue(newConfig: FormConfig) {
// setTimeout needed for change detection of disabling tabs
// TODO: change logic to instead disable tabs upon edit mode immediately (without waiting for changes)
setTimeout(() => this.formControl.setValue(newConfig.fieldGroups));
this.formControl.markAsDirty();
}
}
49 changes: 45 additions & 4 deletions src/app/features/public-form/public-form-config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,55 @@
import { Entity } from "../../core/entity/model/entity";
import { DatabaseEntity } from "../../core/entity/database-entity.decorator";
import { DatabaseField } from "../../core/entity/database-field.decorator";
import { LongTextDatatype } from "app/core/basic-datatypes/string/long-text.datatype";
import { FieldGroup } from "app/core/entity-details/form/field-group";

/**
* Each entity of this type defines a new publicly accessible form
* that can be reached through the given route even by users without being logged in.
*/
@DatabaseEntity("PublicFormConfig")
export class PublicFormConfig extends Entity {
@DatabaseField() title: string;
@DatabaseField() description: string;
@DatabaseField() entity: string;
@DatabaseField() columns: FieldGroup[];
static override label = $localize`:PublicFormConfig:Public Form`;
static override labelPlural = $localize`:PublicFormConfig:Public Forms`;
static override route = "admin/public-form";
static override toStringAttributes = ["title"];

@DatabaseField({
label: $localize`:PublicFormConfig:Title`,
})
title: string;

@DatabaseField({
label: $localize`:PublicFormConfig:Form Link ID`,
description: $localize`:PublicFormConfig:The identifier that is part of the link (URL) through which users can access this form (e.g. demo.aam-digital.com/public-form/MY_FORM_LINK_ID)`,
validators: {
required: true,
},
})
route: string;

@DatabaseField({
label: $localize`:PublicFormConfig:Description`,
dataType: LongTextDatatype.dataType,
})
description: string;

@DatabaseField({
label: $localize`:PublicFormConfig:Entity`,
description: $localize`:PublicFormConfig:The type of record that is created when a someone submits the form (e.g. if you select "Note" here, the form will create new entries in your "Notes List" and you can select only fields of your "Note" data structure for this form)`,
editComponent: "EditEntityTypeDropdown",
validators: {
required: true,
},
})
entity: string;

@DatabaseField({
label: $localize`:PublicFormConfig:Columns`,
isArray: true,
})
columns: FieldGroup[];

/** @deprecated use ColumnConfig directly in the columns array instead */
@DatabaseField() prefilled: { [key in string]: any };
Expand Down
8 changes: 7 additions & 1 deletion src/app/features/public-form/public-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@ export class PublicFormComponent<E extends Entity> implements OnInit {

private async loadFormConfig() {
const id = this.route.snapshot.paramMap.get("id");
this.formConfig = await this.entityMapper.load(PublicFormConfig, id);

const publicForms = await this.entityMapper.loadType(PublicFormConfig);

this.formConfig = publicForms.find(
(form: PublicFormConfig) => form.route === id || form.getId(true) === id,
);

this.entityType = this.entities.get(
this.formConfig.entity,
) as EntityConstructor<E>;
Expand Down
Loading

0 comments on commit 6464c5b

Please sign in to comment.