Skip to content
Merged
142 changes: 129 additions & 13 deletions src/angular/src/app/pages/files/bulk-action-bar.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { BulkActionBarComponent } from './bulk-action-bar.component';

Expand Down Expand Up @@ -65,24 +65,32 @@ describe('BulkActionBarComponent', () => {
expect(emitted).toBe(true);
});

it('should emit deleteLocalEvent when Delete Local button is clicked', () => {
let emitted = false;
component.deleteLocalEvent.subscribe(() => (emitted = true));
it('should emit deleteLocalEvent only on the second Delete Local click', () => {
let emitCount = 0;
component.deleteLocalEvent.subscribe(() => (emitCount += 1));

const btn = findButtonByText('Delete Local');
btn.click();
// First click arms the confirm, does NOT emit
findButtonByText('Delete Local').click();
expect(emitCount).toBe(0);

expect(emitted).toBe(true);
// Second click emits
fixture.detectChanges();
findButtonByText('Confirm?').click();
expect(emitCount).toBe(1);
});

it('should emit deleteRemoteEvent when Delete Remote button is clicked', () => {
let emitted = false;
component.deleteRemoteEvent.subscribe(() => (emitted = true));
it('should emit deleteRemoteEvent only on the second Delete Remote click', () => {
let emitCount = 0;
component.deleteRemoteEvent.subscribe(() => (emitCount += 1));

const btn = findButtonByText('Delete Remote');
btn.click();
// First click arms the confirm, does NOT emit
findButtonByText('Delete Remote').click();
expect(emitCount).toBe(0);

expect(emitted).toBe(true);
// Second click emits
fixture.detectChanges();
findButtonByText('Confirm?').click();
expect(emitCount).toBe(1);
});

it('should emit clearEvent when Clear button is clicked', () => {
Expand All @@ -100,3 +108,111 @@ describe('BulkActionBarComponent', () => {
expect(buttons.length).toBe(5);
});
});

describe('BulkActionBarComponent inline bulk delete confirmation', () => {
let fixture: ComponentFixture<BulkActionBarComponent>;
let component: BulkActionBarComponent;

beforeEach(async () => {
vi.useFakeTimers();

await TestBed.configureTestingModule({
imports: [BulkActionBarComponent],
}).compileComponents();

fixture = TestBed.createComponent(BulkActionBarComponent);
fixture.componentRef.setInput('count', 3);
fixture.detectChanges();
component = fixture.componentInstance;
});

afterEach(() => {
vi.useRealTimers();
});

function findButtonByText(text: string): HTMLButtonElement {
const buttons = Array.from(
fixture.nativeElement.querySelectorAll('button'),
) as HTMLButtonElement[];
const btn = buttons.find((el) => el.textContent?.includes(text));
expect(btn).toBeTruthy();
return btn!;
}

it('first click on Delete Local sets confirming state and does not emit', () => {
const spy = vi.spyOn(component.deleteLocalEvent, 'emit');

component.onDeleteLocal();

expect(component.confirmingDelete).toBe('local');
expect(spy).not.toHaveBeenCalled();
});

it('second click on Delete Local emits event and clears state', () => {
const spy = vi.spyOn(component.deleteLocalEvent, 'emit');

component.onDeleteLocal();
expect(component.confirmingDelete).toBe('local');

component.onDeleteLocal();
expect(component.confirmingDelete).toBeNull();
expect(spy).toHaveBeenCalledTimes(1);
});

it('first click on Delete Remote sets confirming state and does not emit', () => {
const spy = vi.spyOn(component.deleteRemoteEvent, 'emit');

component.onDeleteRemote();

expect(component.confirmingDelete).toBe('remote');
expect(spy).not.toHaveBeenCalled();
});

it('second click on Delete Remote emits event and clears state', () => {
const spy = vi.spyOn(component.deleteRemoteEvent, 'emit');

component.onDeleteRemote();
expect(component.confirmingDelete).toBe('remote');

component.onDeleteRemote();
expect(component.confirmingDelete).toBeNull();
expect(spy).toHaveBeenCalledTimes(1);
});

it('confirming state auto-resets after 3 seconds', () => {
component.onDeleteLocal();
expect(component.confirmingDelete).toBe('local');

vi.advanceTimersByTime(3000);
expect(component.confirmingDelete).toBeNull();
});

it('clicking Delete Local while confirming remote switches to local', () => {
component.onDeleteRemote();
expect(component.confirmingDelete).toBe('remote');

component.onDeleteLocal();
expect(component.confirmingDelete).toBe('local');
});

it("button label switches to 'Confirm?' after first Delete Local click", () => {
// Drive through a real DOM click so OnPush marks the view dirty.
findButtonByText('Delete Local').click();
fixture.detectChanges();

expect(component.confirmingDelete).toBe('local');
const btn = findButtonByText('Confirm?');
expect(btn.textContent).toContain('Confirm?');
});

it('ngOnDestroy clears the confirm timer so no reset fires', () => {
component.onDeleteLocal();
expect(component.confirmingDelete).toBe('local');

component.ngOnDestroy();
vi.advanceTimersByTime(5000);

// State stays as-is (timer was cleared, no reset happened)
expect(component.confirmingDelete).toBe('local');
});
});
69 changes: 65 additions & 4 deletions src/angular/src/app/pages/files/bulk-action-bar.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import {
Component, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy, input, output, inject
} from '@angular/core';

@Component({
selector: 'app-bulk-action-bar',
Expand All @@ -8,8 +10,16 @@ import { Component, ChangeDetectionStrategy, input, output } from '@angular/core
<span class="count">{{ count() }} selected</span>
<button class="btn btn-sm btn-outline-primary" (click)="queueEvent.emit()">Queue</button>
<button class="btn btn-sm btn-outline-warning" (click)="stopEvent.emit()">Stop</button>
<button class="btn btn-sm btn-outline-danger" (click)="deleteLocalEvent.emit()">Delete Local</button>
<button class="btn btn-sm btn-outline-danger" (click)="deleteRemoteEvent.emit()">Delete Remote</button>
<button class="btn btn-sm btn-outline-danger"
[class.confirming]="confirmingDelete === 'local'"
(click)="onDeleteLocal()">
{{ confirmingDelete === 'local' ? 'Confirm?' : 'Delete Local' }}
</button>
<button class="btn btn-sm btn-outline-danger"
[class.confirming]="confirmingDelete === 'remote'"
(click)="onDeleteRemote()">
{{ confirmingDelete === 'remote' ? 'Confirm?' : 'Delete Remote' }}
</button>
<button class="btn btn-sm btn-outline-secondary" (click)="clearEvent.emit()">Clear</button>
</div>
`,
Expand All @@ -26,15 +36,66 @@ import { Component, ChangeDetectionStrategy, input, output } from '@angular/core
font-weight: bold;
margin-right: 8px;
}
.btn.confirming {
background-color: var(--bs-danger);
color: #fff;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BulkActionBarComponent {
export class BulkActionBarComponent implements OnDestroy {
private readonly cdr = inject(ChangeDetectorRef);

count = input.required<number>();

queueEvent = output<void>();
stopEvent = output<void>();
deleteLocalEvent = output<void>();
deleteRemoteEvent = output<void>();
clearEvent = output<void>();

// Inline double-click delete confirmation state
confirmingDelete: 'local' | 'remote' | null = null;
private confirmResetTimer: ReturnType<typeof setTimeout> | null = null;

ngOnDestroy(): void {
this.clearConfirmTimer();
}

onDeleteLocal(): void {
if (this.confirmingDelete === 'local') {
this.clearConfirmTimer();
this.confirmingDelete = null;
this.deleteLocalEvent.emit();
} else {
this.setConfirming('local');
}
}

onDeleteRemote(): void {
if (this.confirmingDelete === 'remote') {
this.clearConfirmTimer();
this.confirmingDelete = null;
this.deleteRemoteEvent.emit();
} else {
this.setConfirming('remote');
}
}

private setConfirming(type: 'local' | 'remote'): void {
this.clearConfirmTimer();
this.confirmingDelete = type;
this.confirmResetTimer = setTimeout(() => {
this.confirmingDelete = null;
this.confirmResetTimer = null;
this.cdr.markForCheck();
}, 3000);
}

private clearConfirmTimer(): void {
if (this.confirmResetTimer !== null) {
clearTimeout(this.confirmResetTimer);
this.confirmResetTimer = null;
}
}
}
Loading