Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create new entity from entity-select fields in modal form #2293

Merged
merged 5 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ <h2 mat-dialog-title i18n>Configure Field "{{ entitySchemaField.label }}"</h2>
[options]="typeAdditionalOptions"
[optionToString]="objectToLabel"
[valueMapper]="objectToValue"
[createOption]="createNewAdditionalOption"
[createOption]="createNewAdditionalOptionAsync"
></app-basic-autocomplete>

<button
Expand Down Expand Up @@ -132,7 +132,7 @@ <h2 mat-dialog-title i18n>Configure Field "{{ entitySchemaField.label }}"</h2>
[options]="typeAdditionalOptions"
[optionToString]="objectToLabel"
[valueMapper]="objectToValue"
[createOption]="createNewAdditionalOption"
[createOption]="createNewAdditionalOptionAsync"
></app-basic-autocomplete>
</mat-form-field>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ export class AdminEntityFieldComponent implements OnChanges {
objectToLabel = (v: SimpleDropdownValue) => v?.label;
objectToValue = (v: SimpleDropdownValue) => v?.value;
createNewAdditionalOption: (input: string) => SimpleDropdownValue;
createNewAdditionalOptionAsync = async (input) =>
this.createNewAdditionalOptionAsync(input);

private updateDataTypeAdditional(dataType: string) {
this.resetAdditional();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { centersUnique } from "../../../../child-dev-project/children/demo-data-
import { ConfigurableEnum } from "../configurable-enum";
import { generateFormFieldStory } from "../../../entity/default-datatype/edit-component-story-utils";
import { ConfigurableEnumService } from "../configurable-enum.service";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";
import { mockEntityMapper } from "../../../entity/entity-mapper/mock-entity-mapper-service";

const centerEnum = Object.assign(new ConfigurableEnum("center"), {
values: centersUnique,
});
const mockEnumService = {
getEnum: () => ({ values: centersUnique }),
getEnum: () => new ConfigurableEnum("storybook-enum", centersUnique),
preLoadEnums: () => undefined,
cacheEnum: () => undefined,
};
Expand All @@ -18,7 +20,10 @@ const formFieldStory = generateFormFieldStory(
centerEnum.values[1],
true,
{ additional: "center" },
[{ provide: ConfigurableEnumService, useValue: mockEnumService }],
[
{ provide: ConfigurableEnumService, useValue: mockEnumService },
{ provide: EntityMapperService, useValue: mockEntityMapper() },
],
);

export default {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper
import { MatDialog } from "@angular/material/dialog";
import { of } from "rxjs";
import { EntityAbility } from "../../../permissions/ability/entity-ability";
import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service";

describe("EnumDropdownComponent", () => {
let component: EnumDropdownComponent;
Expand Down Expand Up @@ -72,14 +73,30 @@ describe("EnumDropdownComponent", () => {
expect(component.invalidOptions).toEqual([invalidOption, invalid2]);
});

it("should extend the existing enum with the new option", () => {
it("should extend the existing enum with the new option", async () => {
const confirmationSpy = spyOn(
TestBed.inject<ConfirmationDialogService>(ConfirmationDialogService),
"getConfirmation",
);
const saveSpy = spyOn(TestBed.inject(EntityMapperService), "save");

const enumEntity = new ConfigurableEnum();
enumEntity.values = [{ id: "1", label: "first" }];
component.enumEntity = enumEntity;

const res = component.createNewOption("second");
// abort if confirmation dialog declined
confirmationSpy.and.resolveTo(false);
const resCanceled = await component.createNewOption("second");

expect(confirmationSpy).toHaveBeenCalled();
expect(saveSpy).not.toHaveBeenCalled();
expect(resCanceled).toBeUndefined();

// create and save new upon confirmation
confirmationSpy.and.resolveTo(true);
const res = await component.createNewOption("second");

expect(confirmationSpy).toHaveBeenCalled();
expect(res).toEqual({ id: "second", label: "second" });
expect(enumEntity.values).toEqual([
{ id: "1", label: "first" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ConfigureEnumPopupComponent } from "../configure-enum-popup/configure-e
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { ErrorHintComponent } from "../../../common-components/error-hint/error-hint.component";
import { MatButtonModule } from "@angular/material/button";
import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service";

@Component({
selector: "app-enum-dropdown",
Expand Down Expand Up @@ -43,13 +44,14 @@ export class EnumDropdownComponent implements OnChanges {
options: ConfigurableEnumValue[];
canEdit = false;
enumValueToString = (v: ConfigurableEnumValue) => v?.label;
createNewOption: (input: string) => ConfigurableEnumValue;
createNewOption: (input: string) => Promise<ConfigurableEnumValue>;

constructor(
private enumService: ConfigurableEnumService,
private entityMapper: EntityMapperService,
private ability: EntityAbility,
private dialog: MatDialog,
private confirmation: ConfirmationDialogService,
) {}

ngOnChanges(changes: SimpleChanges): void {
Expand Down Expand Up @@ -77,10 +79,18 @@ export class EnumDropdownComponent implements OnChanges {
return additionalOptions ?? [];
}

private addNewOption(name: string) {
private async addNewOption(name: string) {
const userConfirmed = await this.confirmation.getConfirmation(
$localize`Create new option`,
$localize`Do you want to create the new option "${name}"?`,
);
if (!userConfirmed) {
return undefined;
}

const option = { id: name, label: name };
this.enumEntity.values.push(option);
this.entityMapper.save(this.enumEntity);
await this.entityMapper.save(this.enumEntity);
return option;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,34 @@ const Template: StoryFn<FormComponent<any>> = (args: FormComponent<any>) => ({
});

const fieldConfig: FormFieldConfig = {
id: "relatedEntity",
label: "related entity",
description: "test tooltip",
};
const fieldMultiConfig: FormFieldConfig = {
id: "relatedEntities",
viewComponent: "DisplayEntityArray",
editComponent: "EditEntity",
label: "test related entities label",
label: "related entities (multi select)",
description: "test tooltip",
};
const otherField: FormFieldConfig = {
id: "x",
viewComponent: "DisplayNumber",
editComponent: "EditNumber",
label: "other label",
};

@DatabaseEntity("TestEntityReferenceArrayEntity")
class TestEntity extends Entity {
@DatabaseField({
dataType: "entity-reference-array",
dataType: "entity-array",
additional: User.ENTITY_TYPE,
})
relatedEntities: string[];

@DatabaseField({
dataType: "entity",
additional: User.ENTITY_TYPE,
})
relatedEntity: string;

@DatabaseField() x: number;
}

Expand All @@ -62,6 +70,8 @@ testEntity.relatedEntities = [testUser.getId()];

export const Primary = Template.bind({});
Primary.args = {
fieldGroups: [{ fields: [otherField, fieldConfig, otherField, otherField] }],
fieldGroups: [
{ fields: [otherField, fieldConfig, fieldMultiConfig, otherField] },
],
entity: testEntity,
};
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,8 @@
[value]="inputElement.value"
>
<em
i18n="
Label for adding an option in a dropdown|e.g. Add option My new Option
"
>Add option</em
i18n="Label for adding an option in a dropdown|e.g. Add new My new Option"
>Add new</em
>
{{ inputElement.value }}
</mat-option>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
Validators,
} from "@angular/forms";
import { genders } from "../../../child-dev-project/children/model/genders";
import { ConfirmationDialogService } from "../confirmation-dialog/confirmation-dialog.service";

describe("BasicAutocompleteComponent", () => {
let component: BasicAutocompleteComponent<any, any>;
Expand Down Expand Up @@ -188,16 +187,10 @@ describe("BasicAutocompleteComponent", () => {
});

it("should create new option", fakeAsync(() => {
const createOptionMock = jasmine.createSpy();

component.createOption = createOptionMock;
const newOption = "new option";
const confirmationSpy = spyOn(
TestBed.inject<ConfirmationDialogService>(ConfirmationDialogService),
"getConfirmation",
);
component.createOption = (id) => ({ id: id, label: id });
const createOptionEventSpy = spyOn(
component,
"createOption",
).and.callThrough();
component.options = genders;
const initialValue = genders[0].id;
component.value = initialValue;
Expand All @@ -208,23 +201,21 @@ describe("BasicAutocompleteComponent", () => {
component.showAutocomplete();
component.autocompleteForm.setValue(newOption);

// decline confirmation for new option
confirmationSpy.and.resolveTo(false);
// decline creating new option
createOptionMock.and.resolveTo(undefined);
component.select(newOption);

tick();
expect(confirmationSpy).toHaveBeenCalled();
expect(createOptionEventSpy).not.toHaveBeenCalled();
expect(createOptionMock).toHaveBeenCalled();
expect(component.value).toEqual(initialValue);

// confirm new option
confirmationSpy.calls.reset();
confirmationSpy.and.resolveTo(true);
// successfully add new option
createOptionMock.calls.reset();
createOptionMock.and.resolveTo({ id: newOption, label: newOption });
component.select(newOption);

tick();
expect(confirmationSpy).toHaveBeenCalled();
expect(createOptionEventSpy).toHaveBeenCalledWith(newOption);
expect(createOptionMock).toHaveBeenCalled();
expect(component.value).toEqual(newOption);
}));
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
skip,
startWith,
} from "rxjs/operators";
import { ConfirmationDialogService } from "../confirmation-dialog/confirmation-dialog.service";
import { ErrorStateMatcher } from "@angular/material/core";
import { CustomFormControlDirective } from "./custom-form-control.directive";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
Expand Down Expand Up @@ -95,7 +94,7 @@ export class BasicAutocompleteComponent<O, V = O>

@Input() valueMapper = (option: O) => option as unknown as V;
@Input() optionToString = (option: O) => option?.toString();
@Input() createOption: (input: string) => O;
@Input() createOption: (input: string) => Promise<O>;
@Input() hideOption: (option: O) => boolean = () => false;

/**
Expand All @@ -110,10 +109,12 @@ export class BasicAutocompleteComponent<O, V = O>
map((val) => this.updateAutocomplete(val)),
startWith([] as SelectableOption<O, V>[]),
);
showAddOption = false;
autocompleteFilterFunction: (option: O) => boolean;
@Output() autocompleteFilterChange = new EventEmitter<(o: O) => boolean>();

/** whether the "add new" option is logically allowed in the current context (e.g. not creating a duplicate) */
showAddOption = false;

get displayText() {
const values: V[] = Array.isArray(this.value) ? this.value : [this.value];

Expand Down Expand Up @@ -149,7 +150,6 @@ export class BasicAutocompleteComponent<O, V = O>

constructor(
elementRef: ElementRef<HTMLElement>,
private confirmation: ConfirmationDialogService,
errorStateMatcher: ErrorStateMatcher,
@Optional() @Self() ngControl: NgControl,
@Optional() parentForm: NgForm,
Expand Down Expand Up @@ -224,8 +224,10 @@ export class BasicAutocompleteComponent<O, V = O>
filteredOptions = filteredOptions.filter((o) =>
this.autocompleteFilterFunction(o.initial),
);
this.showAddOption = !this._options.some((o) =>
this.autocompleteFilterFunction(o.initial),

// do not allow users to create a new entry "identical" to an existing one:
this.showAddOption = !this._options.some(
(o) => o.asString.toLowerCase() === inputText.toLowerCase(),
);
}
return filteredOptions;
Expand Down Expand Up @@ -270,12 +272,9 @@ export class BasicAutocompleteComponent<O, V = O>
}

async createNewOption(option: string) {
const userConfirmed = await this.confirmation.getConfirmation(
$localize`Create new option`,
$localize`Do you want to create the new option "${option}"?`,
);
if (userConfirmed) {
const newOption = this.toSelectableOption(this.createOption(option));
const createdOption = await this.createOption(option);
if (createdOption) {
const newOption = this.toSelectableOption(createdOption);
this._options.push(newOption);
this.select(newOption);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
[display]="showEntities ? 'chips' : 'none'"
[options]="availableOptions | async"
[placeholder]="(loading | async) ? loadingPlaceholder : placeholder"
[createOption]="createNewEntity"
>
<ng-template let-item>
<app-display-entity
Expand Down
Loading
Loading