diff --git a/src/app/child-dev-project/attendance/activities-overview/activities-overview.component.ts b/src/app/child-dev-project/attendance/activities-overview/activities-overview.component.ts index 23d06654a3..40c93215f1 100644 --- a/src/app/child-dev-project/attendance/activities-overview/activities-overview.component.ts +++ b/src/app/child-dev-project/attendance/activities-overview/activities-overview.component.ts @@ -22,17 +22,9 @@ export class ActivitiesOverviewComponent { entityCtr = RecurringActivity; - titleColumn: FormFieldConfig = { - id: "title", - editComponent: "EditTextWithAutocomplete", - additional: { - entityType: "RecurringActivity", - relevantProperty: "linkedGroups", - relevantValue: "", - }, - }; + // TODO: write config migration and remove this component override _columns: FormFieldConfig[] = [ - this.titleColumn, + { id: "title" }, { id: "type" }, { id: "assignedTo" }, { id: "linkedGroups" }, @@ -40,7 +32,6 @@ export class ActivitiesOverviewComponent ]; async ngOnInit() { - this.titleColumn.additional.relevantValue = this.entity.getId(); await super.ngOnInit(); } } diff --git a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.html b/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.html deleted file mode 100644 index 11a5359d58..0000000000 --- a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.html +++ /dev/null @@ -1,51 +0,0 @@ - - {{ label }} - - - - - - - - Creating new record. - Editing existing record. - - - - - - - - - diff --git a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.scss b/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.scss deleted file mode 100644 index 6505904428..0000000000 --- a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -.form-field-with-tooltip-suffix { - width: calc(95% - 24px); -} - -.tooltip-suffix { - margin-left: 4px; -} diff --git a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.spec.ts b/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.spec.ts deleted file mode 100644 index 7c232775a6..0000000000 --- a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; -import { FormControl } from "@angular/forms"; -import { RecurringActivity } from "../../../child-dev-project/attendance/model/recurring-activity"; -import { defaultInteractionTypes } from "../../config/default-config/default-interaction-types"; -import { ConfirmationDialogService } from "../confirmation-dialog/confirmation-dialog.service"; -import { EntityFormService } from "../entity-form/entity-form.service"; -import { MockedTestingModule } from "../../../utils/mocked-testing.module"; -import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; -import { EditTextWithAutocompleteComponent } from "./edit-text-with-autocomplete.component"; -import { By } from "@angular/platform-browser"; - -describe("EditTextWithAutocompleteComponent", () => { - let component: EditTextWithAutocompleteComponent; - let fixture: ComponentFixture; - let loadTypeSpy: jasmine.Spy; - let mockConfirmationDialog: jasmine.SpyObj; - - beforeEach(waitForAsync(() => { - mockConfirmationDialog = jasmine.createSpyObj(["getConfirmation"]); - TestBed.configureTestingModule({ - imports: [ - EditTextWithAutocompleteComponent, - MockedTestingModule.withState(), - ], - providers: [ - { - provide: ConfirmationDialogService, - useValue: mockConfirmationDialog, - }, - ], - }).compileComponents(); - })); - - beforeEach(waitForAsync(() => { - fixture = TestBed.createComponent(EditTextWithAutocompleteComponent); - component = fixture.componentInstance; - loadTypeSpy = spyOn(TestBed.inject(EntityMapperService), "loadType"); - loadTypeSpy.and.resolveTo([]); - const entityFormService = TestBed.inject(EntityFormService); - component.parent = entityFormService.createFormGroup( - [ - { id: "title" }, - { id: "type" }, - { id: "assignedTo" }, - { id: "linkedGroups" }, - ], - new RecurringActivity(), - ); - component.formControl = component.parent.get( - "title", - ) as FormControl; - component.formControlName = "title"; - component.formFieldConfig = { - id: "title", - additional: { - entityType: "RecurringActivity", - }, - }; - component.entity = new RecurringActivity(); - fixture.detectChanges(); - })); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - it("should show all entities of the given type", async () => { - const rA1 = RecurringActivity.create("First Recurring Activity"); - const rA2 = RecurringActivity.create("Second Recurring Activity"); - loadTypeSpy.and.resolveTo([rA1, rA2]); - - await component.ngOnInit(); - - expect(loadTypeSpy).toHaveBeenCalled(); - expect(component.entities).toEqual([rA1, rA2]); - component.formControl.setValue("Activity"); - component.updateAutocomplete(); - expect(component.autocompleteEntities.value).toEqual([rA1, rA2]); - }); - - it("should correctly set the form controls to the selected entity's values", async () => { - const rA1 = RecurringActivity.create("First Recurring Activity"); - rA1.type = defaultInteractionTypes[0]; - rA1.assignedTo = ["user1", "user2"]; - rA1.linkedGroups = ["group1", "group2"]; - loadTypeSpy.and.resolveTo([rA1]); - await component.ngOnInit(); - - await component.selectEntity(rA1); - - expect(component.formControl).toHaveValue(rA1.title); - expect(component.parent.get("type")).toHaveValue(rA1.type); - expect(component.parent.get("assignedTo")).toHaveValue(rA1.assignedTo); - expect(component.parent.get("linkedGroups")).toHaveValue(rA1.linkedGroups); - }); - - it("should correctly reset the form to its original values", async () => { - const rA1 = RecurringActivity.create("First Recurring Activity"); - rA1.type = defaultInteractionTypes[0]; - rA1.assignedTo = ["user1", "user2"]; - rA1.linkedGroups = ["group1", "group2"]; - rA1.participants = ["student1", "student2"]; - loadTypeSpy.and.resolveTo([rA1]); - component.parent.get("linkedGroups").setValue(["testgroup1"]); - await component.ngOnInit(); - await component.selectEntity(rA1); - - await component.resetForm(); - - expect(component.formControl).toHaveValue(""); - expect(component.parent.get("type")).toHaveValue(null); - expect(component.parent.get("assignedTo")).toHaveValue([]); - expect(component.parent.get("linkedGroups")).toHaveValue(["testgroup1"]); - expect(component.parent.get("participants")).toBeFalsy(); - }); - - it("should add the given relevantValue to the form control of the relevant property", async () => { - const rA1 = RecurringActivity.create("First Recurring Activity"); - rA1.linkedGroups = ["group1", "group2"]; - loadTypeSpy.and.resolveTo([rA1]); - component.additional.relevantProperty = "linkedGroups"; - component.additional.relevantValue = "group3"; - await component.ngOnInit(); - - await component.selectEntity(rA1); - - expect(component.parent.get("linkedGroups").value).toContain("group3"); - }); - - it("should show name of the selected entity", async () => { - const rA1 = RecurringActivity.create("First Recurring Activity"); - const rA2 = RecurringActivity.create("Second Recurring Activity"); - component.formControl.setValue(rA1.title); - loadTypeSpy.and.resolveTo([rA1, rA2]); - - await component.ngOnInit(); - - fixture.detectChanges(); - const input: HTMLInputElement = fixture.debugElement.query( - By.css("input"), - ).nativeElement; - expect(input.value).toEqual("First Recurring Activity"); - }); - - // check if values in the form control have been manually changed after loading the entity - it("should only load new entity if user confirms to override changes", async () => { - const rA1 = RecurringActivity.create("First Recurring Activity"); - const rA2 = RecurringActivity.create("Second Recurring Activity"); - rA1.type = defaultInteractionTypes[0]; - rA1.assignedTo = ["user1", "user2"]; - rA1.linkedGroups = ["group1", "group2"]; - loadTypeSpy.and.resolveTo([rA1, rA2]); - mockConfirmationDialog.getConfirmation.and.resolveTo(true); - - await component.ngOnInit(); - component.formControl.setValue("test1"); - component.parent.get("assignedTo").setValue(["user3"]); - - await component.selectEntity(rA2); - - expect(component.selectedEntity).toEqual(rA2); - }); -}); diff --git a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.ts b/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.ts deleted file mode 100644 index fc93c48f3b..0000000000 --- a/src/app/core/common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Component, OnInit } from "@angular/core"; -import { EditComponent } from "../../entity/default-datatype/edit-component"; -import { Entity } from "../../entity/model/entity"; -import { BehaviorSubject } from "rxjs"; -import { DynamicComponent } from "../../config/dynamic-components/dynamic-component.decorator"; -import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; -import { FormControl, ReactiveFormsModule } from "@angular/forms"; -import { ConfirmationDialogService } from "../confirmation-dialog/confirmation-dialog.service"; -import { MatFormFieldModule } from "@angular/material/form-field"; -import { MatInputModule } from "@angular/material/input"; -import { MatAutocompleteModule } from "@angular/material/autocomplete"; -import { AsyncPipe, NgForOf, NgIf } from "@angular/common"; -import { EntityBlockComponent } from "../../basic-datatypes/entity/entity-block/entity-block.component"; -import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; -import { MatTooltipModule } from "@angular/material/tooltip"; -import { ErrorHintComponent } from "../error-hint/error-hint.component"; - -/** - * This component creates a normal text input with autocomplete. - * Compared to the {@link EditEntityComponent} this does not just assign the ID to the form control - * but instead completely overwrites the form with the values taken from the selected entity. - * This is especially useful when instead of creating a new entity, an existing one can also be selected (and extended). - * - * When a value is already present the autocomplete is disabled, and it works like a normal text input. - * - * E.g. - * ```json - * { - * "id": "title", - * "editComponent": "EditTextWithAutocomplete", - * "additional": { - * "entityType": "RecurringActivity", - * "relevantProperty": "linkedGroups", - * "relevantValue": "some-id", - * }, - * } - * ``` - */ -@DynamicComponent("EditTextWithAutocomplete") -@Component({ - selector: "app-edit-text-with-autocomplete", - templateUrl: "./edit-text-with-autocomplete.component.html", - styleUrls: ["./edit-text-with-autocomplete.component.scss"], - imports: [ - MatFormFieldModule, - ReactiveFormsModule, - MatInputModule, - MatAutocompleteModule, - AsyncPipe, - EntityBlockComponent, - NgForOf, - NgIf, - FontAwesomeModule, - MatTooltipModule, - ErrorHintComponent, - ], - standalone: true, -}) -export class EditTextWithAutocompleteComponent - extends EditComponent - implements OnInit -{ - /** - * Config passed using component - */ - additional: { - /** - * The entity type for which autofill should be created. - * This should be the same type as for which the form was created. - */ - entityType: string; - /** - * (optional) a property which should be filled with certain value, if an entity is selected. - */ - relevantProperty?: string; - /** - * (optional) required if `relevantProperty` is set. - * The value to be filled in `selectedEntity[relevantProperty]`. - */ - relevantValue?: string; - }; - - entities: Entity[] = []; - autocompleteEntities = new BehaviorSubject(this.entities); - selectedEntity?: Entity; - currentValues; - originalValues; - autocompleteDisabled = true; - lastValue = ""; - addedFormControls = []; - - constructor( - private entityMapperService: EntityMapperService, - private confirmationDialog: ConfirmationDialogService, - ) { - super(); - } - - keyup() { - this.lastValue = this.formControl.value; - this.updateAutocomplete(); - } - - updateAutocomplete() { - let val = this.formControl.value; - if ( - !this.autocompleteDisabled && - val !== this.currentValues[this.formControlName] - ) { - let filteredEntities = this.entities; - if (val) { - filteredEntities = this.entities.filter( - (entity) => - entity !== this.selectedEntity && - entity.toString().toLowerCase().includes(val.toLowerCase()), - ); - } - this.autocompleteEntities.next(filteredEntities); - } - } - - async ngOnInit() { - super.ngOnInit(); - if (!this.formControl.value) { - // adding new entry - enable autocomplete - const entityType = this.additional.entityType; - this.entities = await this.entityMapperService.loadType(entityType); - this.entities.sort((e1, e2) => - e1.toString().localeCompare(e2.toString()), - ); - this.autocompleteDisabled = false; - this.currentValues = this.parent.getRawValue(); - this.originalValues = this.currentValues; - } - } - - async selectEntity(selected: Entity) { - if (await this.userConfirmsOverwriteIfNecessary(selected)) { - this.selectedEntity = selected; - this.addRelevantValueToRelevantProperty(this.selectedEntity); - this.setAllFormValues(this.selectedEntity); - this.currentValues = this.parent.getRawValue(); - this.autocompleteEntities.next([]); - } else { - this.formControl.setValue(this.lastValue); - } - } - - private async userConfirmsOverwriteIfNecessary(entity: Entity) { - return ( - !this.valuesChanged() || - this.confirmationDialog.getConfirmation( - $localize`:Discard the changes made:Discard changes`, - $localize`Do you want to discard the changes made to '${entity}'?`, - ) - ); - } - - private valuesChanged() { - return Object.entries(this.currentValues).some( - ([prop, value]) => - prop !== this.formControlName && - value !== this.parent.controls[prop].value, - ); - } - - private addRelevantValueToRelevantProperty(selected: Entity) { - if ( - this.additional.relevantProperty && - this.additional.relevantValue && - !selected[this.additional.relevantProperty].includes( - this.additional.relevantValue, - ) - ) { - selected[this.additional.relevantProperty].push( - this.additional.relevantValue, - ); - } - } - - private setAllFormValues(selected: Entity) { - Object.keys(selected) - .filter((key) => selected.getSchema().has(key)) - .forEach((key) => { - if (this.parent.controls.hasOwnProperty(key)) { - this.parent.controls[key].setValue(this.selectedEntity[key]); - } else { - // adding missing controls so saving does not lose any data - this.parent.addControl(key, new FormControl(selected[key])); - this.addedFormControls.push(key); - } - }); - } - - async resetForm() { - if (await this.userConfirmsOverwriteIfNecessary(this.selectedEntity)) { - this.addedFormControls.forEach((control) => - this.parent.removeControl(control), - ); - this.addedFormControls = []; - this.formControl.reset(); - this.parent.patchValue(this.originalValues); - this.selectedEntity = null; - this.currentValues = this.originalValues; - } - } -} diff --git a/src/app/core/common-components/pill/pill.component.scss b/src/app/core/common-components/pill/pill.component.scss index 51b1a58bc0..3ddcfb94d4 100644 --- a/src/app/core/common-components/pill/pill.component.scss +++ b/src/app/core/common-components/pill/pill.component.scss @@ -2,7 +2,9 @@ :host { padding: sizes.$small sizes.$regular; - border-radius: 100vmax; + border-radius: 12px; + height: fit-content; width: max-content; display: inline-block; + background-color: rgba(0, 0, 0, 0.10); } diff --git a/src/app/core/core-components.ts b/src/app/core/core-components.ts index 0b5d2f0337..4159e5b3b6 100644 --- a/src/app/core/core-components.ts +++ b/src/app/core/core-components.ts @@ -36,13 +36,6 @@ export const coreComponents: ComponentTuple[] = [ "./basic-datatypes/entity/display-entity/display-entity.component" ).then((c) => c.DisplayEntityComponent), ], - [ - "EditTextWithAutocomplete", - () => - import( - "./common-components/edit-text-with-autocomplete/edit-text-with-autocomplete.component" - ).then((c) => c.EditTextWithAutocompleteComponent), - ], [ "EditAge", () => @@ -218,4 +211,11 @@ export const coreComponents: ComponentTuple[] = [ "./entity-details/related-entities-with-summary/related-entities-with-summary.component" ).then((c) => c.RelatedEntitiesWithSummaryComponent), ], + [ + "ExistingEntityLoad", + () => + import( + "./entity-duplicates/existing-entity-load/existing-entity-load.component" + ).then((c) => c.ExistingEntityLoadComponent), + ], ]; diff --git a/src/app/core/entity-duplicates/entity-duplicates.service.spec.ts b/src/app/core/entity-duplicates/entity-duplicates.service.spec.ts new file mode 100644 index 0000000000..634c725f64 --- /dev/null +++ b/src/app/core/entity-duplicates/entity-duplicates.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from "@angular/core/testing"; + +import { EntityDuplicatesService } from "./entity-duplicates.service"; + +describe("EntityDuplicatesService", () => { + let service: EntityDuplicatesService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(EntityDuplicatesService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/entity-duplicates/entity-duplicates.service.ts b/src/app/core/entity-duplicates/entity-duplicates.service.ts new file mode 100644 index 0000000000..391e13dd17 --- /dev/null +++ b/src/app/core/entity-duplicates/entity-duplicates.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@angular/core"; +import { EntityMapperService } from "../entity/entity-mapper/entity-mapper.service"; +import { Entity } from "../entity/model/entity"; + +/** + * Find (possibly) duplicate entities or otherwise similar records. + * + * This service can for example be used to support de-duplication actions + * or give users options to link existing entities instead of creating a new one. + */ +@Injectable({ + providedIn: "root", +}) +export class EntityDuplicatesService { + constructor(private entityMapper: EntityMapperService) {} + + getSimilarEntities( + entity: E, + filterValues: any, + ): Promise { + return this.entityMapper.loadType(entity.getType()); + } +} diff --git a/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.html b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.html new file mode 100644 index 0000000000..be8d2d53a5 --- /dev/null +++ b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.html @@ -0,0 +1,49 @@ +@if (defaultEntity.isNew) { + 0 ? 'visible' : 'hidden' + }" + > + + @if (selectedEntity) { + + + + Editing existing record + + + + create new record instead + + } @else { + + Load & link existing record + + + + + + + + + + } + + +} diff --git a/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.scss b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.scss new file mode 100644 index 0000000000..a0c5f7d12f --- /dev/null +++ b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.scss @@ -0,0 +1,13 @@ + +.card { + padding: 1em; +} + +.select-suggested { + width: 340px; +} + +.warning-editing { + margin: auto; + color: red; +} diff --git a/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.spec.ts b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.spec.ts new file mode 100644 index 0000000000..be9364d037 --- /dev/null +++ b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.spec.ts @@ -0,0 +1,150 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from "@angular/core/testing"; + +import { ExistingEntityLoadComponent } from "./existing-entity-load.component"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { ConfirmationDialogService } from "../../common-components/confirmation-dialog/confirmation-dialog.service"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { EntityFormService } from "../../common-components/entity-form/entity-form.service"; +import { RecurringActivity } from "../../../child-dev-project/attendance/model/recurring-activity"; +import { defaultInteractionTypes } from "../../config/default-config/default-interaction-types"; +import { SimpleChange } from "@angular/core"; + +describe("ExistingEntityLoadComponent", () => { + let component: ExistingEntityLoadComponent; + let fixture: ComponentFixture; + + let loadTypeSpy: jasmine.Spy; + let mockConfirmationDialog: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + mockConfirmationDialog = jasmine.createSpyObj(["getConfirmation"]); + TestBed.configureTestingModule({ + imports: [ExistingEntityLoadComponent, MockedTestingModule.withState()], + providers: [ + { + provide: ConfirmationDialogService, + useValue: mockConfirmationDialog, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ExistingEntityLoadComponent); + component = fixture.componentInstance; + loadTypeSpy = spyOn(TestBed.inject(EntityMapperService), "loadType"); + loadTypeSpy.and.resolveTo([]); + const entityFormService = TestBed.inject(EntityFormService); + + component.form = entityFormService.createFormGroup( + [ + { id: "title" }, + { id: "type" }, + { id: "assignedTo" }, + { id: "linkedGroups" }, + ], + new RecurringActivity(), + ); + component.defaultEntity = new RecurringActivity(); + fixture.detectChanges(); + })); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should show all entities of the given type", fakeAsync(() => { + const rA1 = RecurringActivity.create("First Recurring Activity"); + const rA2 = RecurringActivity.create("Second Recurring Activity"); + loadTypeSpy.and.resolveTo([rA1, rA2]); + + component.ngOnChanges({ form: new SimpleChange(null, null, null) }); + tick(); + + expect(loadTypeSpy).toHaveBeenCalled(); + expect(component.suggestedEntities.value).toEqual([rA1, rA2]); + })); + + it("should correctly set the form controls to the selected entity's values, when user selects existing suggestion", fakeAsync(() => { + const rA1 = RecurringActivity.create("First Recurring Activity"); + rA1.type = defaultInteractionTypes[0]; + rA1.assignedTo = ["user1", "user2"]; + rA1.linkedGroups = ["group1", "group2"]; + loadTypeSpy.and.resolveTo([rA1]); + component.ngOnChanges({ form: new SimpleChange(null, null, null) }); + tick(); + + component.selectEntity(rA1); + + expect(component.form.get("title")).toHaveValue(rA1.title); + expect(component.form.get("type")).toHaveValue(rA1.type); + expect(component.form.get("assignedTo")).toHaveValue(rA1.assignedTo); + expect(component.form.get("linkedGroups")).toHaveValue(rA1.linkedGroups); + })); + + it("should correctly reset the form to its original values", fakeAsync(() => { + const rA1 = RecurringActivity.create("First Recurring Activity"); + rA1.type = defaultInteractionTypes[0]; + rA1.assignedTo = ["user1", "user2"]; + rA1.linkedGroups = ["group1", "group2"]; + rA1.participants = ["student1", "student2"]; + loadTypeSpy.and.resolveTo([rA1]); + component.form.get("linkedGroups").setValue(["testgroup1"]); + component.ngOnChanges({ form: new SimpleChange(null, null, null) }); + tick(); + component.selectEntity(rA1); + tick(); + + component.resetToCreateNew(); + tick(); + + expect(component.selectedEntity).toBeUndefined(); + expect(component.form.get("title")).toHaveValue(""); + expect(component.form.get("type")).toHaveValue(null); + expect(component.form.get("assignedTo")).toHaveValue([]); + expect(component.form.get("linkedGroups")).toHaveValue(["testgroup1"]); + expect(component.form.get("participants")).toBeFalsy(); + })); + + it("should add the given values from the default entity (e.g. the ref to link) to the form control of the relevant property", fakeAsync(() => { + const rA1 = RecurringActivity.create("First Recurring Activity"); + rA1.linkedGroups = ["group1", "group2"]; + loadTypeSpy.and.resolveTo([rA1]); + component.defaultEntity = Object.assign(new RecurringActivity(), { + linkedGroups: "group3", + }); + component.ngOnChanges({ form: new SimpleChange(null, null, null) }); + tick(); + + component.selectEntity(rA1); + + expect(component.form.get("linkedGroups").value).toContain("group3"); + })); + + // check if values in the form control have been manually changed after loading the entity + it("should only load new entity if user confirms to override changes", fakeAsync(() => { + const rA1 = RecurringActivity.create("First Recurring Activity"); + const rA2 = RecurringActivity.create("Second Recurring Activity"); + rA1.type = defaultInteractionTypes[0]; + rA1.assignedTo = ["user1", "user2"]; + rA1.linkedGroups = ["group1", "group2"]; + loadTypeSpy.and.resolveTo([rA1, rA2]); + mockConfirmationDialog.getConfirmation.and.resolveTo(true); + + component.ngOnChanges({ form: new SimpleChange(null, null, null) }); + tick(); + + component.form.get("title").setValue("test1"); + component.form.get("assignedTo").setValue(["user3"]); + + component.selectEntity(rA2); + tick(); + + expect(mockConfirmationDialog.getConfirmation).toHaveBeenCalled(); + expect(component.selectedEntity).toEqual(rA2); + })); +}); diff --git a/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.ts b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.ts new file mode 100644 index 0000000000..3812b56704 --- /dev/null +++ b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.component.ts @@ -0,0 +1,210 @@ +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms"; +import { EntitySelectComponent } from "../../common-components/entity-select/entity-select.component"; +import { Entity } from "../../entity/model/entity"; +import { BasicAutocompleteComponent } from "../../common-components/basic-autocomplete/basic-autocomplete.component"; +import { EntityDuplicatesService } from "../entity-duplicates.service"; +import { BehaviorSubject, merge, of, switchMap } from "rxjs"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { AsyncPipe, NgIf, NgStyle, NgTemplateOutlet } from "@angular/common"; +import { EntityBlockComponent } from "../../basic-datatypes/entity/entity-block/entity-block.component"; +import { + MatFormField, + MatLabel, + MatSuffix, +} from "@angular/material/form-field"; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; +import { MatTooltip } from "@angular/material/tooltip"; +import { ConfirmationDialogService } from "../../common-components/confirmation-dialog/confirmation-dialog.service"; +import { MatOption, MatSelect } from "@angular/material/select"; +import { HelpButtonComponent } from "../../common-components/help-button/help-button.component"; +import { MatCard } from "@angular/material/card"; +import { MatSlideToggle } from "@angular/material/slide-toggle"; +import { MatButton } from "@angular/material/button"; + +/** + * Offer the user an option to select an existing entity that is similar to the data filled in the form + * (instead of creating that as a new entity). + */ +@UntilDestroy() +@Component({ + selector: "app-existing-entity-load", + standalone: true, + imports: [ + EntitySelectComponent, + BasicAutocompleteComponent, + AsyncPipe, + NgTemplateOutlet, + EntityBlockComponent, + MatFormField, + MatLabel, + FaIconComponent, + MatTooltip, + NgIf, + ReactiveFormsModule, + MatSelect, + MatOption, + NgStyle, + MatSuffix, + HelpButtonComponent, + MatCard, + MatSlideToggle, + MatButton, + ], + templateUrl: "./existing-entity-load.component.html", + styleUrl: "./existing-entity-load.component.scss", +}) +export class ExistingEntityLoadComponent + implements OnChanges +{ + /** + * the overall form, containing all relevant entity fields of the entity + * for which this component should monitor possible duplicates + */ + @Input() form: FormGroup; + @Input() entityType: string; + + /** + * newly created entity with default values + * especially including the property needed to link this to the related entity from which the form may have been opened. + */ + @Input() defaultEntity: E; + + suggestedEntities: BehaviorSubject = new BehaviorSubject([]); + selectedEntity?: E; + + addedFormControls: string[] = []; + selectedEntityForm: FormControl; + + constructor( + private entityDuplicatesService: EntityDuplicatesService, + private confirmationDialog: ConfirmationDialogService, + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.form) { + this.initDuplicateLoader(); + } + } + + private initDuplicateLoader() { + this.selectedEntityForm = new FormControl(this.selectedEntity); + this.selectedEntityForm.valueChanges.subscribe((v) => this.selectEntity(v)); + + merge(of({}), this.form.valueChanges) + .pipe( + switchMap((formValues) => + this.entityDuplicatesService.getSimilarEntities( + this.defaultEntity, + formValues, + ), + ), + untilDestroyed(this), + ) + .subscribe((v) => { + this.suggestedEntities.next(v); + }); + } + + async selectEntity(selected: E) { + if (await this.userConfirmsOverwriteIfNecessary(this.selectedEntity)) { + this.selectedEntity = selected; + this.addRelevantValueToRelevantProperty(this.selectedEntity); + this.setAllFormValues(this.selectedEntity); + // TODO: required? this.currentValues = this.parent.getRawValue(); + } else { + return; + //this.selectedEntity = this.previousSelectedEntity; + } + } + + private async userConfirmsOverwriteIfNecessary(entity: Entity) { + return ( + !this.valuesChanged() || + this.confirmationDialog.getConfirmation( + $localize`:Discard the changes made:Discard changes`, + $localize`Do you want to discard the changes made to '${entity}'?`, + ) + ); + } + + private valuesChanged() { + // TODO: reliably detect changes? + return this.selectedEntity && this.form.dirty; + + /* + return Object.entries(this.currentValues).some( + ([prop, value]) => + prop !== this.formControlName && + value !== this.parent.controls[prop].value, + ); + */ + } + + /** + * Apply the values to the selected entity that link this to the related entity + * @private + */ + private addRelevantValueToRelevantProperty(selected: Entity) { + if (!selected) { + return; + } + + for (const [key, value] of Object.entries(this.defaultEntity)) { + const fieldSchema = this.defaultEntity.getSchema().get(key); + if (!fieldSchema) { + continue; + } + + if (fieldSchema.isArray) { + if (!Array.isArray(this.defaultEntity[key])) { + continue; + } + + if (!selected[key]) { + selected[key] = []; + } + + this.defaultEntity[key].forEach((v) => { + if (!selected[key].includes(v)) { + selected[key].push(v); + } + }); + } else { + if (selected.hasOwnProperty(key)) { + // do not overwrite an existing, single-value field + continue; + } + + selected[key] = this.defaultEntity[key]; + } + } + } + + private setAllFormValues(selected: Entity) { + // TODO: reset additionally added values from previously selected + this.form.reset(); + + if (selected) { + Object.keys(selected) + .filter((key) => selected.getSchema().has(key)) + .forEach((key) => { + if (this.form.controls.hasOwnProperty(key)) { + this.form.controls[key].setValue(selected[key]); + } else { + // adding missing controls so saving does not lose any data + this.form.addControl(key, new FormControl(selected[key])); + this.addedFormControls.push(key); + } + }); + + // enable save button: + this.form.markAsDirty(); + } + } + + resetToCreateNew() { + this.selectedEntity = undefined; + this.setAllFormValues(this.defaultEntity); + } +} diff --git a/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.stories.ts b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.stories.ts new file mode 100644 index 0000000000..75fcb32951 --- /dev/null +++ b/src/app/core/entity-duplicates/existing-entity-load/existing-entity-load.stories.ts @@ -0,0 +1,46 @@ +import { applicationConfig, Meta, StoryFn } from "@storybook/angular"; +import { StorybookBaseModule } from "../../../utils/storybook-base.module"; +import { importProvidersFrom } from "@angular/core"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { School } from "../../../child-dev-project/schools/model/school"; +import { MAT_DIALOG_DATA } from "@angular/material/dialog"; +import { + DetailsComponentData, + RowDetailsComponent, +} from "../../form-dialog/row-details/row-details.component"; +import { genders } from "../../../child-dev-project/children/model/genders"; + +const child1 = Child.create("John Doe"); +child1.gender = genders[1]; +const child2 = Child.create("Jane X"); +const child3 = Child.create("Max"); +const school1 = School.create({ name: "School 1" }); + +export default { + title: "Core/Entities/Load Existing into form", + component: RowDetailsComponent, + decorators: [ + applicationConfig({ + providers: [ + importProvidersFrom( + StorybookBaseModule.withData([child1, child2, child3, school1]), + ), + { + provide: MAT_DIALOG_DATA, + useValue: { + entity: new Child(), + columns: [{ id: "name" }, { id: "gender" }], + } as DetailsComponentData, + }, + ], + }), + ], +} as Meta; + +const Template: StoryFn = (args: RowDetailsComponent) => ({ + component: RowDetailsComponent, + props: args, +}); + +export const Primary = Template.bind({}); +Primary.args = {}; diff --git a/src/app/core/form-dialog/row-details/row-details.component.html b/src/app/core/form-dialog/row-details/row-details.component.html index b4520176aa..533b6a0472 100644 --- a/src/app/core/form-dialog/row-details/row-details.component.html +++ b/src/app/core/form-dialog/row-details/row-details.component.html @@ -11,18 +11,30 @@ > - - + + - - + + + + @for (col of viewOnlyColumns; track col.id) { + + + + } diff --git a/src/app/core/form-dialog/row-details/row-details.component.ts b/src/app/core/form-dialog/row-details/row-details.component.ts index 2c47908c6e..5a1b1589ab 100644 --- a/src/app/core/form-dialog/row-details/row-details.component.ts +++ b/src/app/core/form-dialog/row-details/row-details.component.ts @@ -8,7 +8,7 @@ import { } from "../../common-components/entity-form/entity-form.service"; import { DialogCloseComponent } from "../../common-components/dialog-close/dialog-close.component"; import { EntityFormComponent } from "../../common-components/entity-form/entity-form/entity-form.component"; -import { NgForOf, NgIf } from "@angular/common"; +import { AsyncPipe, NgComponentOutlet, NgForOf, NgIf } from "@angular/common"; import { PillComponent } from "../../common-components/pill/pill.component"; import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive"; import { MatTooltipModule } from "@angular/material/tooltip"; @@ -18,6 +18,7 @@ import { EntityArchivedInfoComponent } from "../../entity-details/entity-archive import { FieldGroup } from "../../entity-details/form/field-group"; import { EntityFieldEditComponent } from "../../common-components/entity-field-edit/entity-field-edit.component"; import { EntityFieldViewComponent } from "../../common-components/entity-field-view/entity-field-view.component"; +import { DynamicComponentPipe } from "../../config/dynamic-components/dynamic-component.pipe"; /** * Data interface that must be given when opening the dialog @@ -51,6 +52,9 @@ export interface DetailsComponentData { EntityArchivedInfoComponent, EntityFieldEditComponent, EntityFieldViewComponent, + AsyncPipe, + DynamicComponentPipe, + NgComponentOutlet, ], standalone: true, })