Very descript problem here.
I have a very generic ListView component that I have been building. This component has a fair amount of functionality in it that is working (customizable columns, searching, sorting). I’m running into an issue currently as I develop it, specifically with selection of items (records) inside of this ListView component.
I’m going to try to simplify the problem in this post, but in case more is necessary I am going to post the entire component as well:
The Setup
There are three groups of items in the ListView component: records (all items), filtered, and displayedRecords (observable of filtered records or all records, depending on if filtering has been applied).
I wanted to be able to provide records as well as selection state upon initializing of those records upon initialization. The way I achieved this is by using an input called selectionModel which is an Observable<Map<T, boolean>> that can be provided and/or updated after initialization by the parent. The list view itself has an independent selectedRecords: Map<T, boolean> that the parent can override.
This is where my problem gets a little complicated. The parent may want to verify whether or not an item in the ListView can be selected before it actually is. The ListView has an optional input:
@Input() public onRecordSelecting?: (record: T, next: boolean) => Observable<boolean>
Inside of the template, the displayed records are printed in table rows. If the ListView allows multi-select, checkboxes are added to the front of each item.
Here’s a simplified version of the records being rendered in the template:
<tr *ngFor="let record of this.displayedRecords$ | async">
<td *ngIf="this.isMultiSelect">
<input
type="checkbox"
[checked]="this.isRecordSelected(record)"
(click)="this.toggleRecordSelected(record, $event)" />
</td>
<td> .... RENDER RECORD COLUMNS... </td>
</tr>
The [checked] state for the input references a method in the template that checks the selectedRecords map for the presence of the record:
public isRecordSelected(record: T): boolean {
return this.selectedRecords.get(record) as boolean;
}
The (click) event on the input calls the toggleRecordSelected method:
public toggleRecordSelected(record: T, event: Event): void {
event.stopPropagation();
event.preventDefault(); // Needed so that the checkbox does not change state automatically
this.setRecordSelected(record, event.target.checked);
}
The setRecordSelected method is the method that can (potentially) make a call to the onRecordSelecting observable to determine if the record should be selected. A simplified version looks like this:
private async setRecordSelected(record: T, next: boolean): Promise<void> {
// If the record selection is not going to change, return
if (this.isRecordSelected(record) == next) {
return;
}
// If onRecordSelecting is defined, wait on it to determine if the updating the selection state should occur. If it is undefined, allow the ListView to update the selection state.
let shouldUpdate: boolean = false;
if (this.onRecordSelecting !== undefined) {
await this.onRecordSelecting(record, next).subscribe({
next: (shouldProceed: boolean) => {
shouldUpdate = shouldProceed;
}
});
} else {
shouldUpdate = true;
}
// If the parent (via observable) determined the update should not occur, return
if (!shouldUpdate) {
return;
}
this.selectedRecords.set(record, next);
}
(I wasn’t originally using Promises or async/await operators, but it was what I ended up with in my first iteration of trying to get this to work).
All of this works. The selectedRecords map is accurately updated with and without the onRecordSelecting: Observable<boolean>.
The Problem
The problem I am trying to account for is that when the toggleRecordSelected(record: T, event: Event): void is raised by the checkbox in the template, I have the following line:
event.preventDefault();
The reason I felt the need to do this is because without it, the checkbox’s checked state will always change. However, with the design of this component, the parent has the ability to intervene so to speak and reject the change. Without the event.preventDefault() line the parent could reject the change but the checkbox in the view will still change.
The problem I’m trying to resolve is syncing the [checked] state of the checkbox with the selection state in the component’s selectionMap. To reiterate, the input has the following…
<input type="checkbox" [checked]="this.isRecordSelected(record)" ... />
…, and the isRecordSelected method checks the selectionMap. This call to the isRecordSelected method only happens when the component is initialized and not after a change has occurred. If I was able to manually tell the input to re-run the isRecordSelected check , I’m confident this would work.
What I Already Tried
I’ve tried several solutions prior to turning for help:
-
I originally had the isRecordSelected(record: T): boolean outside of the ListView component (it was provided as an optional Input parameter by the parent. This seems to be subject to the same problem (in fact, I couldn’t even get the initial selection state of the records using this).
-
I tried detecting changes in the ListView component. I wasn’t able to get the input elements to re-perform the isRecordSelected check.
-
I experimented with having the setRecordSelection method have a signature of setRecordSelected(record: T, next: boolean): Promise<boolean> where the Promise<boolean> return value actually represented if a change was made or not. This would accurately reflect back to the toggleRecordSelected method (which calls the setRecordSelected method), but I was unable to manually update the checked state of the checkbox. I was trying something along the lines of:
public async toggleRecordSelected(record: T, event: Event): Promise<void> {
event.stopPropagation();
event.preventDefault();
const next: boolean = event.target.checked;
await this.setRecordSelected(record, next).then((changed: boolean) => {
if (changed) {
event.target.checked = next;
}
});
}
I wasn’t having any success manually updating the checkbox’s [checked] state via the component (despite having an accurate reference to it from the $event that was provided to the function).
I know that’s pretty long winded. I’d appreciate any help or insight. Willing to refactor or reorganize the way I’m achieving certain things if that proves necessary (God knows I’ve done it a lot already).