Skip to content
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
4 changes: 2 additions & 2 deletions src/app/core/admin/admin-overview/admin-overview.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, inject, computed } from "@angular/core";
import { Component, inject, computed, signal } from "@angular/core";
import { AdminSectionStateService } from "./admin-section-state.service";
import { BackupService } from "../backup/backup.service";
import { ConfirmationDialogService } from "../../common-components/confirmation-dialog/confirmation-dialog.service";
Expand Down Expand Up @@ -142,7 +142,7 @@ export class AdminOverviewComponent {

snackBarRef.onAction().subscribe(async () => {
const progressRef = this.confirmationDialog.showProgressDialog(
$localize`Reverting configuration changes ...`,
signal($localize`Reverting configuration changes ...`),
);
await undoAction();
progressRef.close();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, NgZone, inject } from "@angular/core";
import { Injectable, NgZone, inject, Signal } from "@angular/core";
import { MatDialog } from "@angular/material/dialog";
import {
ConfirmationDialogButton,
Expand Down Expand Up @@ -68,7 +68,7 @@ export class ConfirmationDialogService {
* Use the returned dialogRef to close the dialog once your processing is completed.
* @param message
*/
showProgressDialog(message: string) {
showProgressDialog(message: Signal<string>) {
return this.dialog.open(ProgressDialogComponent, {
data: { message },
minWidth: "50vh",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<h1 mat-dialog-title>
{{ data.message }}
{{ message() }}
</h1>

<div mat-dialog-content>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";

import { ProgressDialogComponent } from "./progress-dialog.component";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { signal } from "@angular/core";

describe("ProgressDialogComponent", () => {
let component: ProgressDialogComponent;
Expand All @@ -14,7 +15,7 @@ describe("ProgressDialogComponent", () => {
{ provide: MatDialogRef, useValue: {} },
{
provide: MAT_DIALOG_DATA,
useValue: { message: "test title" },
useValue: { message: signal("test title") },
},
],
}).compileComponents();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, inject } from "@angular/core";
import { Component, inject, Signal } from "@angular/core";
import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog";
import { MatProgressBarModule } from "@angular/material/progress-bar";

Expand All @@ -11,7 +11,9 @@ import { MatProgressBarModule } from "@angular/material/progress-bar";
imports: [MatProgressBarModule, MatDialogModule],
})
export class ProgressDialogComponent {
data = inject<{
message: string;
private readonly initialData = inject<{
message: Signal<string>;
}>(MAT_DIALOG_DATA);

message = this.initialData.message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Entity } from "../../entity/model/entity";
import { AlertService } from "app/core/alerts/alert.service";
import { getUrlWithoutParams } from "app/utils/utils";
import { Router } from "@angular/router";
import { BulkOperationStateService } from "../../entity/entity-actions/bulk-operation-state.service";

@Injectable({
providedIn: "root",
Expand All @@ -14,6 +15,7 @@ export class DuplicateRecordService {
private readonly entityService = inject(EntitySchemaService);
private readonly alertService = inject(AlertService);
private readonly router = inject(Router);
private readonly bulkOperationState = inject(BulkOperationStateService);

async duplicateRecord(
sourceData: Entity | Entity[],
Expand All @@ -22,7 +24,15 @@ export class DuplicateRecordService {
const entities = Array.isArray(sourceData) ? sourceData : [sourceData];
const duplicateData = this.clone(entities);

await this.entityMapperService.saveAll(duplicateData);
this.bulkOperationState.startBulkOperation(duplicateData.length);

try {
await this.entityMapperService.saveAll(duplicateData);
} catch (error) {
this.bulkOperationState.completeBulkOperation();
throw error;
}

this.alertService.addInfo(this.generateSuccessMessage(entities));

if (navigate) {
Expand Down
26 changes: 22 additions & 4 deletions src/app/core/entity-list/entity-list/entity-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { Sort } from "@angular/material/sort";
import { ExportColumnConfig } from "../../export/data-transformation-service/export-column-config";
import { RouteTarget } from "../../../route-target";
import { EntitiesTableComponent } from "../../common-components/entities-table/entities-table.component";
import { applyUpdate } from "../../entity/model/entity-update";
import { applyUpdate, UpdatedEntity } from "../../entity/model/entity-update";
import { Subscription } from "rxjs";
import { DataFilter } from "../../filter/filters/filters";
import { EntityCreateButtonComponent } from "../../common-components/entity-create-button/entity-create-button.component";
Expand All @@ -58,6 +58,7 @@ import { EntityLoadPipe } from "../../common-components/entity-load/entity-load.
import { PublicFormConfig } from "#src/app/features/public-form/public-form-config";
import { PublicFormsService } from "#src/app/features/public-form/public-forms.service";
import { EntityBulkActionsComponent } from "../../entity-details/entity-bulk-actions/entity-bulk-actions.component";
import { BulkOperationStateService } from "../../entity/entity-actions/bulk-operation-state.service";

/**
* This component allows to create a full-blown table with pagination, filtering, searching and grouping.
Expand Down Expand Up @@ -115,6 +116,7 @@ export class EntityListComponent<T extends Entity>
optional: true,
});
private readonly formDialog = inject(FormDialogService);
private readonly bulkOperationState = inject(BulkOperationStateService);

private readonly publicFormsService = inject(PublicFormsService);
public publicFormConfigs: PublicFormConfig[] = [];
Expand Down Expand Up @@ -277,15 +279,31 @@ export class EntityListComponent<T extends Entity>
this.updateSubscription = this.entityMapperService
.receiveUpdates(this.entityConstructor)
.pipe(untilDestroyed(this))
.subscribe(async (updatedEntity) => {
// get specially enhanced entity if necessary
.subscribe(async (updatedEntity: UpdatedEntity<T>) => {
if (this.bulkOperationState.isBulkOperationInProgress()) {
//buffer updates during bulk operations to avoid UI performance issues
const inProgress =
this.bulkOperationState.updateBulkOperationProgress(1, false);
if (!inProgress) {
// reload the list once
this.allEntities = await this.getEntities();
// Use setTimeout and requestAnimationFrame to detect when UI rendering is complete and inform the bulk action update
setTimeout(() => {
requestAnimationFrame(() => {
this.bulkOperationState.completeBulkOperation();
});
});
}
return;
}

//get specially enhanced entity if necessary
if (this.loaderMethod && this.entitySpecialLoader) {
updatedEntity = await this.entitySpecialLoader.extendUpdatedEntity(
this.loaderMethod,
updatedEntity,
);
}

this.allEntities = applyUpdate(this.allEntities, updatedEntity);
});
}
Expand Down
120 changes: 120 additions & 0 deletions src/app/core/entity/entity-actions/bulk-operation-state.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
inject,
Injectable,
OnDestroy,
signal,
WritableSignal,
} from "@angular/core";
import { BehaviorSubject } from "rxjs";
import { ConfirmationDialogService } from "../../common-components/confirmation-dialog/confirmation-dialog.service";
import { MatDialogRef } from "@angular/material/dialog";

/**
* Service to communicate bulk operation status between edit service and list components
*/
@Injectable({
providedIn: "root",
})
export class BulkOperationStateService implements OnDestroy {
private readonly confirmationDialog = inject(ConfirmationDialogService);

private readonly operationInProgress = new BehaviorSubject<boolean>(false);
private progressDialogRef: MatDialogRef<any> | null = null;
private readonly progressDialogMessage: WritableSignal<string> = signal(
$localize`:Bulk edit progress message:Preparing bulk action ...`,
);
private expectedUpdateCount = 0;
private processedUpdateCount = 0;

isBulkOperationInProgress$ = this.operationInProgress.asObservable();
isBulkOperationInProgress(): boolean {
return this.operationInProgress.value;
}

/**
* Start a bulk operation
*/
startBulkOperation(expectedCount?: number) {
this.expectedUpdateCount = expectedCount || 0;
this.processedUpdateCount = 0;
this.operationInProgress.next(true);
this.updateProgressDialog();
this.progressDialogRef = this.confirmationDialog.showProgressDialog(
this.progressDialogMessage,
);
}

/**
* Update bulk operation progress based on received entity updates.
* Called by list components when they receive entity update events.
*
* @return boolean - whether the bulk operation is still in progress
*/
updateBulkOperationProgress(
count: number,
autoCompleteBulkOperation?: boolean,
): boolean {
if (!this.operationInProgress.value) {
return false;
}

this.processedUpdateCount += count;
this.updateProgressDialog();

if (this.processedUpdateCount >= this.expectedUpdateCount) {
if (autoCompleteBulkOperation) {
this.completeBulkOperation();
}
return false;
}

return true;
}

/**
* Get expected update count
*/
getExpectedUpdateCount(): number {
return this.expectedUpdateCount;
}

/**
* Get current processed update count
*/
getProcessedUpdateCount(): number {
return this.processedUpdateCount;
}

/**
* Update progress dialog with current progress
*/
private updateProgressDialog() {
this.progressDialogMessage.set(
$localize`:Bulk edit progress message:Updated ${this.processedUpdateCount} of ${this.expectedUpdateCount} records...`,
);
}

/**
* Complete a bulk operation.
*
* Called automatically, unless there is errors during the bulk operation,
* in which case the caller should close the operation manually
*/
completeBulkOperation() {
this.operationInProgress.next(false);
this.processedUpdateCount = 0;
this.expectedUpdateCount = 0;
if (this.progressDialogRef) {
this.progressDialogRef.close();
this.progressDialogRef = null;
}
}

ngOnDestroy() {
if (this.operationInProgress.value) {
this.completeBulkOperation();
}

this.operationInProgress.complete();
}
}
36 changes: 17 additions & 19 deletions src/app/core/entity/entity-actions/entity-actions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { EntityDeleteService } from "./entity-delete.service";
import { EntityAnonymizeService } from "./entity-anonymize.service";
import { CascadingActionResult } from "./cascading-entity-action";
import { PublicFormsService } from "app/features/public-form/public-forms.service";
import { BulkOperationStateService } from "./bulk-operation-state.service";

describe("EntityActionsService", () => {
let service: EntityActionsService;
Expand All @@ -25,6 +26,7 @@ describe("EntityActionsService", () => {
let mockRouter;
let mockedEntityDeleteService: jasmine.SpyObj<EntityDeleteService>;
let mockedEntityAnonymizeService: jasmine.SpyObj<EntityAnonymizeService>;
let mockedBulkOperationState: jasmine.SpyObj<BulkOperationStateService>;

let singleTestEntity: Entity;
let severalTestEntities: Entity[] = [];
Expand All @@ -43,6 +45,10 @@ describe("EntityActionsService", () => {
mockedEntityAnonymizeService.anonymizeEntity.and.resolveTo(
new CascadingActionResult([singleTestEntity]),
);
mockedBulkOperationState = jasmine.createSpyObj([
"startBulkOperation",
"completeBulkOperation",
]);
mockedEntityMapper = jasmine.createSpyObj([
"save",
"saveAll",
Expand Down Expand Up @@ -85,6 +91,10 @@ describe("EntityActionsService", () => {
provide: PublicFormsService,
useValue: jasmine.createSpyObj(["initCustomFormActions"]),
},
{
provide: BulkOperationStateService,
useValue: mockedBulkOperationState,
},
],
});
mockRouter = TestBed.inject(Router);
Expand Down Expand Up @@ -292,15 +302,9 @@ describe("EntityActionsService", () => {

const result = await service.archive(severalTestEntities);
expect(result).toBe(true);
expect(mockedEntityMapper.save).toHaveBeenCalledTimes(3);
expect(mockedEntityMapper.save).toHaveBeenCalledWith(
expectedSavedEntities[0],
);
expect(mockedEntityMapper.save).toHaveBeenCalledWith(
expectedSavedEntities[1],
);
expect(mockedEntityMapper.save).toHaveBeenCalledWith(
expectedSavedEntities[2],
expect(mockedEntityMapper.saveAll).toHaveBeenCalledTimes(1);
expect(mockedEntityMapper.saveAll).toHaveBeenCalledWith(
expectedSavedEntities,
);
expect(snackBarSpy.open).toHaveBeenCalled();
});
Expand All @@ -322,17 +326,11 @@ describe("EntityActionsService", () => {
expectedSavedEntities.forEach((e) => (e.inactive = true));

await service.archive(severalTestEntities);
expect(mockedEntityMapper.save).toHaveBeenCalledTimes(3);
expect(mockedEntityMapper.save).toHaveBeenCalledWith(
expectedSavedEntities[0],
);
expect(mockedEntityMapper.save).toHaveBeenCalledWith(
expectedSavedEntities[1],
);
expect(mockedEntityMapper.save).toHaveBeenCalledWith(
expectedSavedEntities[2],
expect(mockedEntityMapper.saveAll).toHaveBeenCalledTimes(1);
expect(mockedEntityMapper.saveAll).toHaveBeenCalledWith(
expectedSavedEntities,
);
mockedEntityMapper.save.calls.reset();
mockedEntityMapper.saveAll.calls.reset();

await service.undoArchive(severalTestEntities);
expect(mockedEntityMapper.save).toHaveBeenCalledWith(
Expand Down
Loading
Loading