Skip to content

Commit 9929d03

Browse files
sadaf895sleidigAbhinegi2
authored
refactor: bulk actions to use a dynamic service registering available actions (#3366)
and use a dropdown UI closes #2894 --------- Co-authored-by: Sebastian Leidig <[email protected]> Co-authored-by: Abhishek Negi <[email protected]>
1 parent d49ce1e commit 9929d03

21 files changed

+426
-230
lines changed

src/app/core/common-components/entities-table/entities-table.component.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import {
33
Component,
44
ContentChildren,
55
EventEmitter,
6+
inject,
67
Input,
78
Output,
89
QueryList,
910
ViewChild,
10-
inject,
1111
} from "@angular/core";
1212
import {
1313
MatCheckboxChange,
@@ -312,13 +312,12 @@ export class EntitiesTableComponent<T extends Entity>
312312
selectRow(row: TableRow<T>, checked: boolean) {
313313
if (checked) {
314314
if (!this.selectedRecords.includes(row.record)) {
315-
this.selectedRecords.push(row.record);
316-
}
317-
} else {
318-
const index = this.selectedRecords.indexOf(row.record);
319-
if (index > -1) {
320-
this.selectedRecords.splice(index, 1);
315+
this.selectedRecords = [...this.selectedRecords, row.record];
321316
}
317+
} else if (this.selectedRecords.includes(row.record)) {
318+
this.selectedRecords = this.selectedRecords.filter(
319+
(r) => r !== row.record,
320+
);
322321
}
323322
this.selectedRecordsChange.emit(this.selectedRecords);
324323
}

src/app/core/entity-details/entity-actions-menu/entity-action.interface.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { EntityActionPermission } from "../../permissions/permission-types";
55
* Details of an action that users can trigger for a specific entity, displayed in the context menu.
66
*/
77
export interface EntityAction {
8+
/**
9+
* Determines where this action is available:
10+
* - 'all': available for both individual and bulk actions (default)
11+
* - 'bulk-only': only available as a bulk action
12+
* - 'individual-only': only available for single entity actions
13+
*/
14+
availableFor?: "all" | "bulk-only" | "individual-only";
15+
816
/**
917
* ID for identifying this action in analytics, etc.
1018
*/
@@ -31,11 +39,14 @@ export interface EntityAction {
3139
* The method being executed when the action is triggered.
3240
* @param e The entity on which the action is executed
3341
*/
34-
execute: (entity: Entity, navigateOnDelete?: boolean) => Promise<boolean>;
42+
execute: (
43+
entity: Entity | Entity[],
44+
navigateOnDelete?: boolean,
45+
) => Promise<boolean>;
3546

3647
/**
3748
* Controls visibility of the action based on the given entity.
3849
* Should return a Promise resolving to true if visible.
3950
*/
40-
visible?: (entity: Entity) => Promise<boolean>;
51+
visible?: (entity: Entity | Entity[]) => Promise<boolean>;
4152
}

src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ describe("EntityActionsMenuComponent", () => {
3535
label: "Test Action",
3636
icon: "test_icon",
3737
execute: () => null,
38+
availableFor: "all",
3839
};
3940
spyOn(testAction, "execute").and.resolveTo(true);
4041

src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import {
22
Component,
33
EventEmitter,
4+
inject,
45
Input,
56
OnChanges,
67
Output,
78
SimpleChanges,
8-
inject,
99
} from "@angular/core";
1010
import { Entity } from "../../entity/model/entity";
1111
import { MatButtonModule } from "@angular/material/button";
@@ -55,9 +55,9 @@ export class EntityActionsMenuComponent implements OnChanges {
5555
*/
5656
@Input() showExpanded?: boolean;
5757

58-
ngOnChanges(changes: SimpleChanges): void {
58+
async ngOnChanges(changes: SimpleChanges): Promise<void> {
5959
if (changes.entity) {
60-
this.filterAvailableActions();
60+
await this.filterAvailableActions();
6161
}
6262
}
6363

@@ -66,23 +66,11 @@ export class EntityActionsMenuComponent implements OnChanges {
6666
this.actions = [];
6767
return;
6868
}
69-
const allActions: EntityAction[] = this.entityActionsMenuService.getActions(
69+
70+
const allActions = await this.entityActionsMenuService.getActionsForSingle(
7071
this.entity,
7172
);
72-
73-
// check each action’s `visible` property to hide actions not applicable to the current entity
74-
const visibleActions = (
75-
await Promise.all(
76-
allActions.map(async (action) => {
77-
const isVisible = action.visible
78-
? await action.visible(this.entity)
79-
: true;
80-
return isVisible ? action : null;
81-
}),
82-
)
83-
).filter(Boolean) as EntityAction[];
84-
85-
this.actions = visibleActions;
73+
this.actions = allActions;
8674
}
8775

8876
async executeAction(action: EntityAction) {

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,45 @@ export class EntityActionsMenuService {
2424
];
2525
}
2626

27+
/**
28+
* Get only actions available for bulk operations.
29+
*/
30+
async getVisibleActions(
31+
entities: Entity | Entity[],
32+
availableFor: "bulk-only" | "individual-only" | "all",
33+
): Promise<EntityAction[]> {
34+
const entity = Array.isArray(entities) ? entities[0] : entities;
35+
const actions = this.getActions(entity).filter((action) => {
36+
const avail = action.availableFor ?? "all";
37+
return avail === availableFor || avail === "all";
38+
});
39+
const visibleActions: EntityAction[] = [];
40+
for (const action of actions) {
41+
let isVisible: boolean;
42+
if (action.visible) {
43+
isVisible = await action.visible(entities);
44+
} else {
45+
isVisible = true;
46+
}
47+
if (isVisible) {
48+
visibleActions.push(action);
49+
}
50+
}
51+
return visibleActions;
52+
}
53+
54+
async getActionsForBulk(entities?: Entity[]): Promise<EntityAction[]> {
55+
return this.getVisibleActions(entities ?? [], "bulk-only");
56+
}
57+
58+
/**
59+
* Get only actions available for single entity operations.
60+
* @param entity
61+
*/
62+
async getActionsForSingle(entity?: Entity): Promise<EntityAction[]> {
63+
return this.getVisibleActions(entity, "individual-only");
64+
}
65+
2766
/**
2867
* Add (static) actions to be shown for all entity actions context menus.
2968
*/
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<div class="flex-row gap-regular overall-box">
2+
<div class="bulk-action-select-wrapper">
3+
<mat-form-field class="bulk-action-autocomplete full-width">
4+
<mat-label i18n>Select Bulk Action</mat-label>
5+
6+
<app-basic-autocomplete
7+
[formControl]="actionControl"
8+
[options]="bulkActions.value()"
9+
[optionToString]="actionToString"
10+
display="text"
11+
></app-basic-autocomplete>
12+
13+
@if (!entities() || entities().length === 0) {
14+
<mat-hint i18n>
15+
Please select one or more rows to enable bulk actions.
16+
</mat-hint>
17+
} @else {
18+
<mat-hint i18n>
19+
Action will execute on {{ entities().length }} selected records.
20+
</mat-hint>
21+
}
22+
</mat-form-field>
23+
</div>
24+
<button mat-raised-button (click)="cancel()" i18n>Cancel</button>
25+
</div>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
@use "@angular/material/core/style/elevation" as mat-elevation;
2+
3+
.overall-box {
4+
@include mat-elevation.elevation(3);
5+
padding: 1em;
6+
margin: 1em;
7+
}
8+
9+
.bulk-action-select-wrapper {
10+
position: relative;
11+
flex: 1 1 0%;
12+
min-width: 320px;
13+
max-width: 700px;
14+
width: 400px; /* Fixed width for stability */
15+
}
16+
17+
.bulk-action-autocomplete {
18+
/* remove form-field bottom spacing */
19+
margin-bottom: 0;
20+
}
21+
.bulk-action-autocomplete.full-width {
22+
width: 100%;
23+
min-width: 0;
24+
max-width: 100%;
25+
box-sizing: border-box;
26+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
Component,
3+
inject,
4+
input,
5+
output,
6+
resource,
7+
effect,
8+
} from "@angular/core";
9+
import { FormControl, ReactiveFormsModule } from "@angular/forms";
10+
import { Entity } from "../../entity/model/entity";
11+
import { EntityActionsMenuService } from "../entity-actions-menu/entity-actions-menu.service";
12+
import { EntityAction } from "../entity-actions-menu/entity-action.interface";
13+
import {
14+
BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS,
15+
BasicAutocompleteComponent,
16+
} from "../../common-components/basic-autocomplete/basic-autocomplete.component";
17+
import { MatButtonModule } from "@angular/material/button";
18+
19+
/**
20+
* Allow users to select among the registered bulk actions
21+
* that are available in the current context.
22+
* Also executes the action.
23+
*/
24+
@Component({
25+
selector: "app-entity-bulk-actions",
26+
templateUrl: "./entity-bulk-actions.component.html",
27+
styleUrls: ["./entity-bulk-actions.component.scss"],
28+
standalone: true,
29+
imports: [
30+
MatButtonModule,
31+
ReactiveFormsModule,
32+
BasicAutocompleteComponent,
33+
...BASIC_AUTOCOMPLETE_COMPONENT_IMPORTS,
34+
],
35+
})
36+
export class EntityBulkActionsComponent {
37+
/**
38+
* List of selected entities for bulk actions
39+
*/
40+
entities = input.required<Entity[]>();
41+
private isExecutingAction = false;
42+
43+
/**
44+
* Event emitted when the bulk action mode should be exited
45+
* after an action was executed or the user cancelled the bulk actions.
46+
*/
47+
resetBulkActionMode = output();
48+
49+
private readonly actionsService = inject(EntityActionsMenuService);
50+
51+
/**
52+
* Available bulk actions for the current selection
53+
*/
54+
bulkActions = resource({
55+
params: () => ({ entities: this.entities() }),
56+
loader: async ({ params }) => {
57+
const bulkActions = await this.actionsService.getActionsForBulk(
58+
params.entities,
59+
);
60+
return bulkActions
61+
.map((action) => {
62+
if (action.action === "merge") {
63+
return {
64+
...action,
65+
disabled: !this.entities() || this.entities().length !== 2,
66+
};
67+
}
68+
return action;
69+
})
70+
.filter((action) => !!action);
71+
},
72+
defaultValue: [],
73+
});
74+
75+
actionControl = new FormControl();
76+
actionToString = (action: EntityAction) => action?.label || "";
77+
78+
constructor() {
79+
this.actionControl.valueChanges.subscribe((action) => {
80+
if (action) this.onActionSelected(action);
81+
});
82+
// Enable/disable actionControl based on entities selection
83+
effect(() => {
84+
const entities = this.entities();
85+
if (!entities || entities.length === 0) {
86+
this.actionControl.disable({ emitEvent: false });
87+
} else {
88+
this.actionControl.enable({ emitEvent: false });
89+
}
90+
});
91+
}
92+
93+
async onActionSelected(action: EntityAction) {
94+
if (this.isExecutingAction) {
95+
return;
96+
}
97+
98+
this.isExecutingAction = true;
99+
if (action && typeof action.execute === "function") {
100+
await action.execute(this.entities());
101+
}
102+
103+
this.resetBulkActionMode.emit();
104+
this.actionControl.setValue(null, { emitEvent: false });
105+
this.isExecutingAction = false;
106+
}
107+
108+
cancel() {
109+
this.resetBulkActionMode.emit();
110+
}
111+
}

0 commit comments

Comments
 (0)