Skip to content

Commit 4a0e252

Browse files
authored
feat: bulk edit - update a field for multiple entities at once (#2565)
closes #2291
1 parent 82ab22c commit 4a0e252

File tree

13 files changed

+714
-96
lines changed

13 files changed

+714
-96
lines changed

src/app/core/entity-list/entity-list/entity-list.component.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,15 @@ <h2>{{ title }}</h2>
258258
matTooltip="Select rows for an action on multiple records"
259259
i18n-matTooltip
260260
>
261+
<button
262+
mat-raised-button
263+
(click)="editRecords()"
264+
[disabled]="selectedRows.length === 0"
265+
color="accent"
266+
i18n="bulk action button"
267+
>
268+
Bulk Edit
269+
</button>
261270
<button
262271
mat-raised-button
263272
(click)="archiveRecords()"

src/app/core/entity-list/entity-list/entity-list.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
EntitySpecialLoaderService,
6161
LoaderMethod,
6262
} from "../../entity/entity-special-loader/entity-special-loader.service";
63+
import { EntityEditService } from "app/core/entity/entity-actions/entity-edit.service";
6364

6465
/**
6566
* This component allows to create a full-blown table with pagination, filtering, searching and grouping.
@@ -181,6 +182,7 @@ export class EntityListComponent<T extends Entity>
181182
private dialog: MatDialog,
182183
private duplicateRecord: DuplicateRecordService,
183184
private entityActionsService: EntityActionsService,
185+
private entityEditService: EntityEditService,
184186
@Optional() private entitySpecialLoader: EntitySpecialLoaderService,
185187
) {
186188
this.screenWidthObserver
@@ -319,6 +321,13 @@ export class EntityListComponent<T extends Entity>
319321
this.selectedRows = undefined;
320322
}
321323

324+
async editRecords() {
325+
await this.entityEditService.edit(
326+
this.selectedRows,
327+
this.entityConstructor,
328+
);
329+
this.selectedRows = undefined;
330+
}
322331
async deleteRecords() {
323332
await this.entityActionsService.delete(this.selectedRows);
324333
this.selectedRows = undefined;

src/app/core/entity/entity-actions/entity-actions.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ export class EntityActionsService {
286286
return true;
287287
}
288288

289-
private generateMessageForConfirmationWithUndo(
289+
public generateMessageForConfirmationWithUndo(
290290
entities: Entity[],
291291
action: string,
292292
): string {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<h2 mat-dialog-title i18n>
2+
Bulk edit {{ entitiesToEdit.length }} {{ entityConstructor.labelPlural }}
3+
</h2>
4+
<app-dialog-close mat-dialog-close></app-dialog-close>
5+
6+
<mat-dialog-content>
7+
<p i18n>
8+
You are about to modify the selected records. This action will apply changes
9+
across multiple entries. Make sure that the fields you are updating reflect
10+
the correct information for all selected records.<br />
11+
If you are unsure about making changes across all these records, review your
12+
selection carefully before proceeding or edit the records individually.
13+
</p>
14+
15+
<form>
16+
<div class="entity-form-cell">
17+
<mat-form-field appearance="fill">
18+
<mat-label i18n>Property to update</mat-label>
19+
<mat-select
20+
[formControl]="selectedFieldFormControl"
21+
(selectionChange)="onChangeProperty($event.value)"
22+
>
23+
<mat-option *ngFor="let field of entityFields" [value]="field.key">
24+
{{ field.label }}
25+
</mat-option>
26+
</mat-select>
27+
</mat-form-field>
28+
</div>
29+
30+
<div *ngIf="showValueForm" class="entity-form-cell">
31+
<app-entity-field-edit
32+
[field]="selectedField"
33+
[entity]="entityData"
34+
[form]="fieldValueForm"
35+
></app-entity-field-edit>
36+
</div>
37+
</form>
38+
</mat-dialog-content>
39+
40+
<mat-dialog-actions>
41+
<button mat-button (click)="save()" i18n="Button label">Save</button>
42+
<button mat-button mat-dialog-close i18n="Button label">Cancel</button>
43+
</mat-dialog-actions>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.entity-form-cell {
2+
mat-form-field,
3+
::ng-deep .mat-mdc-form-field
4+
{
5+
width: 100%;
6+
}
7+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ComponentFixture, TestBed } from "@angular/core/testing";
2+
import { EntityBulkEditComponent } from "./entity-bulk-edit.component";
3+
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
4+
import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing";
5+
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
6+
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
7+
import { AdminEntityService } from "app/core/admin/admin-entity.service";
8+
import { EntityFormService } from "app/core/common-components/entity-form/entity-form.service";
9+
10+
describe("EntityBulkEditComponent", () => {
11+
let component: EntityBulkEditComponent<any>;
12+
let fixture: ComponentFixture<EntityBulkEditComponent<any>>;
13+
let mockDialogRef: jasmine.SpyObj<MatDialogRef<EntityBulkEditComponent<any>>>;
14+
let mockEntityFormService: jasmine.SpyObj<EntityFormService>;
15+
16+
const mockEntityConstructor = {
17+
schema: new Map([
18+
["name", { label: "foo" }],
19+
["gender", { label: "Male" }],
20+
]),
21+
};
22+
23+
const mockEntityData = {
24+
getConstructor: () => mockEntityConstructor,
25+
formData: {
26+
name: "Value 1",
27+
gender: "Value 2",
28+
},
29+
};
30+
31+
beforeEach(async () => {
32+
mockDialogRef = jasmine.createSpyObj("MatDialogRef", ["close"]);
33+
mockEntityFormService = jasmine.createSpyObj("EntityFormService", [
34+
"createEntityForm",
35+
"extendFormFieldConfig",
36+
]);
37+
38+
await TestBed.configureTestingModule({
39+
imports: [
40+
EntityBulkEditComponent,
41+
FontAwesomeTestingModule,
42+
NoopAnimationsModule,
43+
ReactiveFormsModule,
44+
],
45+
providers: [
46+
{
47+
provide: MAT_DIALOG_DATA,
48+
useValue: {
49+
entitiesToEdit: [mockEntityData],
50+
entityConstructor: mockEntityConstructor,
51+
},
52+
},
53+
{ provide: MatDialogRef, useValue: mockDialogRef },
54+
{ provide: EntityFormService, useValue: mockEntityFormService },
55+
FormBuilder,
56+
AdminEntityService,
57+
],
58+
}).compileComponents();
59+
60+
fixture = TestBed.createComponent(EntityBulkEditComponent);
61+
component = fixture.componentInstance;
62+
fixture.detectChanges();
63+
});
64+
65+
it("should create the component", () => {
66+
expect(component).toBeTruthy();
67+
});
68+
69+
it("should initialize selectedField with proper values", () => {
70+
component.selectedField = { id: "foo", label: "Test Label" };
71+
72+
component.ngOnInit();
73+
expect(component.selectedFieldFormControl.value).toBe("");
74+
});
75+
76+
it("should fetch and populate entity fields", () => {
77+
component.fetchEntityFieldsData();
78+
79+
expect(component.entityFields.length).toBe(2);
80+
expect(component.entityFields[0].key).toBe("name");
81+
expect(component.entityFields[0].label).toBe("foo");
82+
});
83+
84+
it("should not save if the form is invalid", () => {
85+
component.selectedFieldFormControl.setValue("");
86+
87+
component.save();
88+
89+
expect(mockDialogRef.close).not.toHaveBeenCalled();
90+
});
91+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { Component, Inject, OnInit } from "@angular/core";
2+
import {
3+
MAT_DIALOG_DATA,
4+
MatDialogModule,
5+
MatDialogRef,
6+
} from "@angular/material/dialog";
7+
import { MatButtonModule } from "@angular/material/button";
8+
import { DialogCloseComponent } from "app/core/common-components/dialog-close/dialog-close.component";
9+
import { MatInputModule } from "@angular/material/input";
10+
import { ErrorHintComponent } from "app/core/common-components/error-hint/error-hint.component";
11+
import { EntityFieldEditComponent } from "app/core/common-components/entity-field-edit/entity-field-edit.component";
12+
import {
13+
FormControl,
14+
FormsModule,
15+
ReactiveFormsModule,
16+
Validators,
17+
} from "@angular/forms";
18+
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
19+
import { MatTooltipModule } from "@angular/material/tooltip";
20+
import { FormFieldConfig } from "app/core/common-components/entity-form/FormConfig";
21+
import { Entity, EntityConstructor } from "../../model/entity";
22+
import { MatOption } from "@angular/material/core";
23+
import { MatFormFieldModule } from "@angular/material/form-field";
24+
import { MatSelectModule } from "@angular/material/select";
25+
import { CommonModule } from "@angular/common";
26+
import {
27+
EntityForm,
28+
EntityFormService,
29+
} from "app/core/common-components/entity-form/entity-form.service";
30+
31+
@Component({
32+
selector: "app-entity-bulk-edit",
33+
standalone: true,
34+
imports: [
35+
MatDialogModule,
36+
MatButtonModule,
37+
DialogCloseComponent,
38+
MatInputModule,
39+
ErrorHintComponent,
40+
FormsModule,
41+
ReactiveFormsModule,
42+
FontAwesomeModule,
43+
MatTooltipModule,
44+
MatOption,
45+
MatFormFieldModule,
46+
MatSelectModule,
47+
CommonModule,
48+
EntityFieldEditComponent,
49+
],
50+
templateUrl: "./entity-bulk-edit.component.html",
51+
styleUrl: "./entity-bulk-edit.component.scss",
52+
})
53+
export class EntityBulkEditComponent<E extends Entity> implements OnInit {
54+
entityConstructor: EntityConstructor;
55+
entitiesToEdit: E[];
56+
57+
selectedFieldFormControl: FormControl;
58+
fieldValueForm: EntityForm<E>;
59+
60+
/**
61+
* The available fields of the entity, from which the user can choose.
62+
*/
63+
entityFields: Array<{ key: string; label: string; field: any }> = [];
64+
65+
entityData: E;
66+
showValueForm: boolean = false;
67+
selectedField: FormFieldConfig;
68+
69+
constructor(
70+
@Inject(MAT_DIALOG_DATA)
71+
data: {
72+
entitiesToEdit: E[];
73+
entityConstructor: EntityConstructor;
74+
},
75+
private dialogRef: MatDialogRef<any>,
76+
private entityFormService: EntityFormService,
77+
) {
78+
this.entityConstructor = data.entityConstructor;
79+
this.entityData = data.entitiesToEdit[0];
80+
this.entitiesToEdit = data.entitiesToEdit;
81+
}
82+
83+
ngOnInit(): void {
84+
this.initForm();
85+
this.fetchEntityFieldsData();
86+
}
87+
88+
private initForm() {
89+
this.selectedFieldFormControl = new FormControl("", Validators.required);
90+
}
91+
92+
fetchEntityFieldsData() {
93+
this.entityFields = Array.from(this.entityConstructor.schema.entries())
94+
.filter(([key, field]) => field.label)
95+
.map(([key, field]) => ({
96+
key: key,
97+
label: field.label,
98+
field: field,
99+
}));
100+
}
101+
102+
async onChangeProperty(fieldId: string) {
103+
this.selectedField = this.entityFormService.extendFormFieldConfig(
104+
fieldId,
105+
this.entityConstructor,
106+
);
107+
108+
this.fetchEntityFieldsData();
109+
110+
const fieldKeys = this.entityFields.map((item) => item.key);
111+
await this.createEntityForm(fieldKeys);
112+
113+
this.showValueForm = true;
114+
}
115+
116+
private async createEntityForm(fieldKeys: string[]) {
117+
this.fieldValueForm = await this.entityFormService.createEntityForm(
118+
fieldKeys,
119+
this.entityData,
120+
);
121+
122+
const selectedField = this.selectedFieldFormControl.value;
123+
if (this.fieldValueForm.formGroup.controls[selectedField]) {
124+
this.fieldValueForm.formGroup.controls[selectedField].setValue("");
125+
}
126+
}
127+
128+
save() {
129+
this.selectedFieldFormControl.markAsTouched();
130+
if (this.selectedFieldFormControl.invalid) return;
131+
132+
const selectedField = this.selectedFieldFormControl.value;
133+
const value =
134+
this.fieldValueForm?.formGroup.controls[selectedField]?.value || "";
135+
136+
const returnValue: BulkEditAction = {
137+
selectedField,
138+
value,
139+
};
140+
this.dialogRef.close(returnValue);
141+
}
142+
}
143+
144+
export interface BulkEditAction {
145+
selectedField: string;
146+
value: any;
147+
}

0 commit comments

Comments
 (0)