@for (execution of group.executions; track execution.id) {
@@ -77,6 +97,14 @@
(click)="selectExecution(execution.id)">
+ @if (isComparing(group.id)) {
+
+ }
{{ runNumberLabel(execution.runNumber) }}
({
+ id: runId,
+ title: runId,
+ flowName: 'Flow',
+ status: 'SUCCESS',
+ startedAt: 'now',
+ creationTime: index + 1,
+ runNumber: index + 1,
+ kind: 'RUN'
+ }))
+ } as unknown as TaskExecutionGroupListItem;
+}
+
+describe('TasksExecutionsListComponent comparison picking', () => {
+ let fixture: ComponentFixture;
+ let component: TasksExecutionsListComponent;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [TasksExecutionsListComponent] }).compileComponents();
+ fixture = TestBed.createComponent(TasksExecutionsListComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('groups', [group('g1', ['e1', 'e2', 'e3'])]);
+ fixture.componentRef.setInput('selectedExecutionId', 'e1');
+ fixture.detectChanges();
+ });
+
+ it('needs two runs before it will compare', () => {
+ component.toggleCompareMode('g1');
+ expect(component.canCompare()).toBe(false);
+
+ component.toggleComparePick('e1');
+ expect(component.canCompare()).toBe(false);
+
+ component.toggleComparePick('e2');
+ expect(component.canCompare()).toBe(true);
+ });
+
+ it('emits the pair, oldest pick first', () => {
+ const compared = vi.fn();
+ component.compareRequested.subscribe(compared);
+
+ component.toggleCompareMode('g1');
+ component.toggleComparePick('e1');
+ component.toggleComparePick('e2');
+ component.submitCompare();
+
+ expect(compared).toHaveBeenCalledWith({ leftId: 'e1', rightId: 'e2' });
+ });
+
+ it('replaces the older pick on a third click rather than refusing it', () => {
+ // Refusing would leave the user hunting for which box to clear.
+ component.toggleCompareMode('g1');
+ component.toggleComparePick('e1');
+ component.toggleComparePick('e2');
+ component.toggleComparePick('e3');
+
+ const compared = vi.fn();
+ component.compareRequested.subscribe(compared);
+ component.submitCompare();
+
+ expect(compared).toHaveBeenCalledWith({ leftId: 'e2', rightId: 'e3' });
+ });
+
+ it('unticks a pick when it is clicked again', () => {
+ component.toggleCompareMode('g1');
+ component.toggleComparePick('e1');
+ component.toggleComparePick('e1');
+
+ expect(component.isComparePick('e1')).toBe(false);
+ expect(component.comparePickCount()).toBe(0);
+ });
+
+ it('does not disturb which run is open', () => {
+ // Ticking a box must not navigate away from what the user is reading.
+ const selected = vi.fn();
+ component.executionSelected.subscribe(selected);
+
+ component.toggleCompareMode('g1');
+ component.toggleComparePick('e2');
+
+ expect(selected).not.toHaveBeenCalled();
+ expect(component.selectedExecutionId()).toBe('e1');
+ });
+
+ it('confines comparison to one group, and clears the picks on leaving', () => {
+ // Runs of different flows share no node ids, so comparing across groups is meaningless.
+ component.toggleCompareMode('g1');
+ component.toggleComparePick('e1');
+ expect(component.isComparing('g1')).toBe(true);
+ expect(component.isComparing('g2')).toBe(false);
+
+ component.toggleCompareMode('g1');
+ expect(component.isComparing('g1')).toBe(false);
+ expect(component.comparePickCount()).toBe(0);
+ });
+
+ it('refuses to emit without a full pair', () => {
+ const compared = vi.fn();
+ component.compareRequested.subscribe(compared);
+
+ component.toggleCompareMode('g1');
+ component.toggleComparePick('e1');
+ component.submitCompare();
+
+ expect(compared).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts
index 22bfb31..21edb84 100644
--- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts
+++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts
@@ -74,6 +74,19 @@ export class TasksExecutionsListComponent {
readonly executionSelected = output();
readonly executionDeleteRequested = output();
readonly executionRerunRequested = output();
+ /** Two runs of one group, picked to be compared. */
+ readonly compareRequested = output<{ leftId: string; rightId: string }>();
+ /**
+ * Which group is in compare mode, and which of its runs are ticked.
+ *
+ * Kept apart from `selectedExecutionId` on purpose: that one drives which run the main panel
+ * shows, and folding the two together would make ticking a box navigate away from what the user
+ * is reading. Confined to one group because comparing runs of different flows is meaningless -
+ * they share no node ids.
+ */
+ private readonly compareGroupId = signal(null);
+ private readonly comparePicks = signal([]);
+
readonly searchTerm = model('');
readonly filter = signal('all');
readonly orderBy = signal('lastExecutionTime');
@@ -246,4 +259,44 @@ export class TasksExecutionsListComponent {
if (filter === 'all') return true;
return getExecutionStatusGroup(status) === filter;
}
+
+ isComparing(groupId: string): boolean {
+ return this.compareGroupId() === groupId;
+ }
+
+ toggleCompareMode(groupId: string, event?: Event) {
+ event?.stopPropagation();
+ const leaving = this.compareGroupId() === groupId;
+ this.compareGroupId.set(leaving ? null : groupId);
+ this.comparePicks.set([]);
+ }
+
+ isComparePick(executionId: string): boolean {
+ return this.comparePicks().includes(executionId);
+ }
+
+ toggleComparePick(executionId: string, event?: Event) {
+ event?.stopPropagation();
+ this.comparePicks.update((current) => {
+ if (current.includes(executionId)) return current.filter((id) => id !== executionId);
+ // Two at a time: a third pick replaces the older one rather than refusing the click, which
+ // would leave the user hunting for which box to clear.
+ return current.length < 2 ? [...current, executionId] : [current[1], executionId];
+ });
+ }
+
+ comparePickCount(): number {
+ return this.comparePicks().length;
+ }
+
+ canCompare(): boolean {
+ return this.comparePicks().length === 2;
+ }
+
+ submitCompare(event?: Event) {
+ event?.stopPropagation();
+ const [leftId, rightId] = this.comparePicks();
+ if (!leftId || !rightId) return;
+ this.compareRequested.emit({ leftId, rightId });
+ }
}