mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Merge PR #1057: shift-click to select a range of rows
This commit is contained in:
+1
-1
@@ -757,7 +757,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 1rem;">
|
<th scope="col" style="width: 1rem;">
|
||||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
|
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" [orderedIds]="cachedSortedDoneIds" (changed)="doneSelectionChanged($event)" />
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Video</th>
|
<th scope="col">Video</th>
|
||||||
<th scope="col">Type</th>
|
<th scope="col">Type</th>
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
sortAscending = false;
|
sortAscending = false;
|
||||||
expandedErrors: Set<string> = new Set<string>();
|
expandedErrors: Set<string> = new Set<string>();
|
||||||
cachedSortedDone: [string, Download][] = [];
|
cachedSortedDone: [string, Download][] = [];
|
||||||
|
// The done ids in rendered order, so a shift-click range follows the sort
|
||||||
|
// the user is looking at rather than the map's insertion order.
|
||||||
|
cachedSortedDoneIds: string[] = [];
|
||||||
lastCopiedErrorId: string | null = null;
|
lastCopiedErrorId: string | null = null;
|
||||||
private previousDownloadType = 'video';
|
private previousDownloadType = 'video';
|
||||||
private addRequestSub?: Subscription;
|
private addRequestSub?: Subscription;
|
||||||
@@ -1532,6 +1535,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
result.reverse();
|
result.reverse();
|
||||||
}
|
}
|
||||||
this.cachedSortedDone = result;
|
this.cachedSortedDone = result;
|
||||||
|
this.cachedSortedDoneIds = result.map(([key]) => key);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleErrorDetail(id: string) {
|
toggleErrorDetail(id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,38 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||||
import { Checkable } from '../interfaces';
|
import { Checkable } from '../interfaces';
|
||||||
|
|
||||||
|
function makeList(ids: string[]): Map<string, Checkable> {
|
||||||
|
const list = new Map<string, Checkable>();
|
||||||
|
for (const id of ids) {
|
||||||
|
list.set(id, { checked: false });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMaster(list: Map<string, Checkable>, orderedIds: string[] | null = null) {
|
||||||
|
const fixture = TestBed.createComponent(SelectAllCheckboxComponent);
|
||||||
|
fixture.componentRef.setInput('id', 'queue');
|
||||||
|
fixture.componentRef.setInput('list', list);
|
||||||
|
if (orderedIds) {
|
||||||
|
fixture.componentRef.setInput('orderedIds', orderedIds);
|
||||||
|
}
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates what the item checkbox does: ngModel writes the new state, then
|
||||||
|
// the change handler reports the click to the master.
|
||||||
|
function clickItem(
|
||||||
|
master: SelectAllCheckboxComponent,
|
||||||
|
list: Map<string, Checkable>,
|
||||||
|
id: string,
|
||||||
|
shift = false,
|
||||||
|
) {
|
||||||
|
const item = list.get(id)!;
|
||||||
|
item.checked = !item.checked;
|
||||||
|
master.selectionChanged(id, shift);
|
||||||
|
}
|
||||||
|
|
||||||
describe('SelectAllCheckboxComponent', () => {
|
describe('SelectAllCheckboxComponent', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -20,4 +52,87 @@ describe('SelectAllCheckboxComponent', () => {
|
|||||||
fixture.componentInstance.clicked();
|
fixture.componentInstance.clicked();
|
||||||
expect(list.get('u1')?.checked).toBe(true);
|
expect(list.get('u1')?.checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shift-click checks every item between the two clicks', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4', 'u5']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u2');
|
||||||
|
clickItem(master, list, 'u4', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends upwards as well as downwards', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u4');
|
||||||
|
clickItem(master, list, 'u2', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shift-clicking a checked box clears the range', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3']);
|
||||||
|
list.forEach((item) => (item.checked = true));
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the rendered order, not the map order', () => {
|
||||||
|
// The done list renders newest-first, so its rendered order is not the
|
||||||
|
// order the entries sit in the map. u2 lies inside the range on screen
|
||||||
|
// and outside it in the map, which is what separates the two.
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4']);
|
||||||
|
const master = makeMaster(list, ['u4', 'u2', 'u3', 'u1']).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u4');
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
// u1 (rendered last) stays clear; u2 is swept up with the range.
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a plain click after a range starts a new anchor', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3', 'u4']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
clickItem(master, list, 'u2', true);
|
||||||
|
clickItem(master, list, 'u4');
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([true, true, false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select-all clears the anchor so the next shift-click is a plain toggle', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3']);
|
||||||
|
const fixture = makeMaster(list);
|
||||||
|
const master = fixture.componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
master.selected = true;
|
||||||
|
master.clicked();
|
||||||
|
master.selected = false;
|
||||||
|
master.clicked();
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a range whose anchor row is gone', () => {
|
||||||
|
const list = makeList(['u1', 'u2', 'u3']);
|
||||||
|
const master = makeMaster(list).componentInstance;
|
||||||
|
|
||||||
|
clickItem(master, list, 'u1');
|
||||||
|
// The anchor finishes downloading and leaves the queue.
|
||||||
|
list.delete('u1');
|
||||||
|
clickItem(master, list, 'u3', true);
|
||||||
|
|
||||||
|
expect([...list.values()].map((i) => i.checked)).toEqual([false, true]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,17 +20,33 @@ import { FormsModule } from "@angular/forms";
|
|||||||
export class SelectAllCheckboxComponent {
|
export class SelectAllCheckboxComponent {
|
||||||
readonly id = input.required<string>();
|
readonly id = input.required<string>();
|
||||||
readonly list = input.required<Map<string, Checkable>>();
|
readonly list = input.required<Map<string, Checkable>>();
|
||||||
|
// The ids in the order the rows are rendered. The done list is sorted for
|
||||||
|
// display, so its order is not the map's insertion order, and a range
|
||||||
|
// selection has to follow what the user sees. Left unset, the map order is
|
||||||
|
// the rendered order.
|
||||||
|
readonly orderedIds = input<string[] | null>(null);
|
||||||
readonly changed = output<number>();
|
readonly changed = output<number>();
|
||||||
|
|
||||||
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
|
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
|
||||||
selected!: boolean;
|
selected!: boolean;
|
||||||
|
|
||||||
|
// The item a range extends from: the last one toggled on its own.
|
||||||
|
private anchorId: string | null = null;
|
||||||
|
|
||||||
clicked() {
|
clicked() {
|
||||||
this.list().forEach(item => item.checked = this.selected);
|
this.list().forEach(item => item.checked = this.selected);
|
||||||
|
// Select-all is not a position, so there is nothing to extend from next.
|
||||||
|
this.anchorId = null;
|
||||||
this.selectionChanged();
|
this.selectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
selectionChanged() {
|
selectionChanged(id?: string, extend = false) {
|
||||||
|
if (id !== undefined) {
|
||||||
|
if (extend && this.anchorId !== null && this.anchorId !== id) {
|
||||||
|
this.applyRange(this.anchorId, id);
|
||||||
|
}
|
||||||
|
this.anchorId = id;
|
||||||
|
}
|
||||||
const masterCheckbox = this.masterCheckbox();
|
const masterCheckbox = this.masterCheckbox();
|
||||||
if (!masterCheckbox)
|
if (!masterCheckbox)
|
||||||
return;
|
return;
|
||||||
@@ -40,4 +56,27 @@ export class SelectAllCheckboxComponent {
|
|||||||
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
|
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
|
||||||
this.changed.emit(checked);
|
this.changed.emit(checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything between the anchor and the just-clicked row takes the state the
|
||||||
|
// click produced, so shift-clicking a checked box clears the range and
|
||||||
|
// shift-clicking an unchecked one fills it.
|
||||||
|
private applyRange(fromId: string, toId: string) {
|
||||||
|
const ids = this.orderedIds() ?? Array.from(this.list().keys());
|
||||||
|
const from = ids.indexOf(fromId);
|
||||||
|
const to = ids.indexOf(toId);
|
||||||
|
// A row can disappear between two clicks (a download finishing moves it
|
||||||
|
// from the queue to the done list); without both ends there is no range.
|
||||||
|
if (from < 0 || to < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = this.list().get(toId)?.checked ?? false;
|
||||||
|
const start = Math.min(from, to);
|
||||||
|
const end = Math.max(from, to);
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
const item = this.list().get(ids[i]);
|
||||||
|
if (item) {
|
||||||
|
item.checked = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,33 @@ describe('ItemCheckboxComponent', () => {
|
|||||||
itemFixture.detectChanges();
|
itemFixture.detectChanges();
|
||||||
expect(itemFixture.componentInstance).toBeTruthy();
|
expect(itemFixture.componentInstance).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports the shift modifier from the click to the master', () => {
|
||||||
|
const masterFixture = TestBed.createComponent(SelectAllCheckboxComponent);
|
||||||
|
masterFixture.componentRef.setInput('id', 'q');
|
||||||
|
masterFixture.componentRef.setInput('list', new Map());
|
||||||
|
masterFixture.detectChanges();
|
||||||
|
const master = masterFixture.componentInstance;
|
||||||
|
const reported: [string | undefined, boolean | undefined][] = [];
|
||||||
|
master.selectionChanged = (id?: string, extend?: boolean) => {
|
||||||
|
reported.push([id, extend]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemFixture = TestBed.createComponent(ItemCheckboxComponent);
|
||||||
|
itemFixture.componentRef.setInput('id', 'row1');
|
||||||
|
itemFixture.componentRef.setInput('master', master);
|
||||||
|
itemFixture.componentRef.setInput('checkable', { checked: false });
|
||||||
|
itemFixture.detectChanges();
|
||||||
|
const item = itemFixture.componentInstance;
|
||||||
|
|
||||||
|
item.clicked(new MouseEvent('click', { shiftKey: true }));
|
||||||
|
item.changed();
|
||||||
|
// The modifier must not stick to the next toggle.
|
||||||
|
item.changed();
|
||||||
|
|
||||||
|
expect(reported).toEqual([
|
||||||
|
['row1', true],
|
||||||
|
['row1', false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { FormsModule } from '@angular/forms';
|
|||||||
selector: 'app-item-checkbox',
|
selector: 'app-item-checkbox',
|
||||||
template: `
|
template: `
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()" [attr.aria-label]="'Select item ' + id()">
|
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (click)="clicked($event)" (change)="changed()" [attr.aria-label]="'Select item ' + id()">
|
||||||
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||||
changeDetection: ChangeDetectionStrategy.Eager,
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule
|
FormsModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -22,4 +22,19 @@ export class ItemCheckboxComponent {
|
|||||||
readonly id = input.required<string>();
|
readonly id = input.required<string>();
|
||||||
readonly master = input.required<SelectAllCheckboxComponent>();
|
readonly master = input.required<SelectAllCheckboxComponent>();
|
||||||
readonly checkable = input.required<Checkable>();
|
readonly checkable = input.required<Checkable>();
|
||||||
|
|
||||||
|
// click fires before change, so the modifier is recorded here and read once
|
||||||
|
// ngModel has written the new state into the checkable. Keyboard activation
|
||||||
|
// fires change without a click, which is a plain toggle.
|
||||||
|
private extend = false;
|
||||||
|
|
||||||
|
clicked(event: MouseEvent) {
|
||||||
|
this.extend = event.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed() {
|
||||||
|
const extend = this.extend;
|
||||||
|
this.extend = false;
|
||||||
|
this.master().selectionChanged(this.id(), extend);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user