From 3eba5815eebdc7c646a1f0effa96b307056bef59 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 08:02:47 +0200 Subject: [PATCH] Show projects with no flows, so a new one is visible Groups were built only from flows, so a project with none did not render at all. Creating your first project therefore looked like the opposite of what happened: the new project was nowhere to be seen, while every pre-existing flow suddenly appeared inside a group - the "No project" bucket, which the list only starts showing once grouping switches on. Every project now gets a group, empty ones included, with an empty state that says how to put a flow in it. Empty groups are still dropped while a search or a filter is narrowing the list, where they would be noise rather than reassurance. The ungrouped bucket, in turn, only appears when something is actually in it. Also stops the dev fake deriving new project ids from the current count: after a delete that reissued a freed id, and any flow still pointing at it reappeared inside the new project. Co-Authored-By: Claude Opus 5 (1M context) --- .../services/projects/projects-call.fake.ts | 4 ++- .../shared/flows-list/flow-grouping.spec.ts | 30 +++++++++++++++---- src/app/shared/flows-list/flow-grouping.ts | 20 ++++++++++--- .../flows-list/flows-group/flows-group.css | 7 +++++ .../flows-list/flows-group/flows-group.html | 5 ++++ src/app/shared/flows-list/flows-list.ts | 4 ++- .../project-dialog/project-dialog.spec.ts | 26 ++++++++++++++++ 7 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 src/app/shared/project-dialog/project-dialog.spec.ts diff --git a/src/app/services/projects/projects-call.fake.ts b/src/app/services/projects/projects-call.fake.ts index 3d5bf90..e7d219c 100644 --- a/src/app/services/projects/projects-call.fake.ts +++ b/src/app/services/projects/projects-call.fake.ts @@ -57,7 +57,9 @@ export class ProjectsCallServiceFake extends ProjectsCallServiceBase { override createProject(draft: ProjectDraft): Observable { return defer(() => { - const id = `p${Object.keys(this.data).length + 1}`; + // Not derived from the current count: after a delete that would reissue a freed id, and any + // flow still pointing at it would silently reappear inside the new project. + const id = crypto.randomUUID(); const now = new Date(); const created: Project = { id, diff --git a/src/app/shared/flows-list/flow-grouping.spec.ts b/src/app/shared/flows-list/flow-grouping.spec.ts index a7c63c3..3f80940 100644 --- a/src/app/shared/flows-list/flow-grouping.spec.ts +++ b/src/app/shared/flows-list/flow-grouping.spec.ts @@ -31,8 +31,18 @@ describe('groupFlowsByProject', () => { const zeta = makeProject('p1', 'Zeta'); const alpha = makeProject('p2', 'Alpha'); - it('returns nothing for an empty flow list', () => { - expect(groupFlowsByProject([], [zeta])).toEqual([]); + it('still shows a project that has no flows, so a new one is visible', () => { + // A project the user just created must appear; otherwise it looks like nothing happened. + const groups = groupFlowsByProject([], [zeta]); + + expect(groups.map((group) => group.key)).toEqual(['p1']); + expect(groups[0].flows).toEqual([]); + }); + + it('never shows an ungrouped bucket when nothing is ungrouped', () => { + const groups = groupFlowsByProject([makeFlow('a', 'p1')], [zeta]); + + expect(groups.map((group) => group.key)).toEqual(['p1']); }); it('orders projects by name and always puts the ungrouped bucket last', () => { @@ -45,12 +55,19 @@ describe('groupFlowsByProject', () => { expect(groups[2].project).toBeNull(); }); - it('drops groups whose flows were all filtered out rather than rendering them empty', () => { - const groups = groupFlowsByProject([makeFlow('a', 'p1')], [zeta, alpha]); + it('drops empty groups while the list is being narrowed', () => { + // With a search term active an empty group is noise, not reassurance. + const groups = groupFlowsByProject([makeFlow('a', 'p1')], [zeta, alpha], { hideEmpty: true }); expect(groups.map((group) => group.key)).toEqual(['p1']); }); + it('keeps every project visible when nothing is narrowing the list', () => { + const groups = groupFlowsByProject([makeFlow('a', 'p1')], [zeta, alpha]); + + expect(groups.map((group) => group.key)).toEqual(['p2', 'p1']); + }); + it('keeps the incoming order within a group, so the list sort still applies', () => { const groups = groupFlowsByProject( [makeFlow('third', 'p1'), makeFlow('first', 'p1'), makeFlow('second', 'p1')], @@ -65,13 +82,14 @@ describe('groupFlowsByProject', () => { // never disappear from the list because of it. const groups = groupFlowsByProject([makeFlow('a', 'deleted-project')], [zeta]); - expect(groups.map((group) => group.key)).toEqual([UNGROUPED_PROJECT_KEY]); - expect(groups[0].flows).toHaveLength(1); + const ungrouped = groups.find((group) => group.key === UNGROUPED_PROJECT_KEY); + expect(ungrouped?.flows).toHaveLength(1); }); it('produces a single ungrouped bucket when there are no projects at all', () => { const groups = groupFlowsByProject([makeFlow('a'), makeFlow('b')], []); + expect(groups).toHaveLength(1); expect(groups[0].key).toBe(UNGROUPED_PROJECT_KEY); expect(groups[0].flows).toHaveLength(2); diff --git a/src/app/shared/flows-list/flow-grouping.ts b/src/app/shared/flows-list/flow-grouping.ts index a80d6f1..cc40ee7 100644 --- a/src/app/shared/flows-list/flow-grouping.ts +++ b/src/app/shared/flows-list/flow-grouping.ts @@ -14,15 +14,24 @@ export type FlowGroupView = { * Rules, fixed deliberately: * - Groups keep the incoming flow order, so whatever sort the list applies holds within a group. * - Projects are ordered by name; the "no project" group is always last. - * - A group with no flows is dropped entirely rather than rendered empty - with a search term - * active, an empty group is noise. + * - Every project gets a group, even with no flows: a project the user just created must be + * visible, otherwise it looks like nothing happened - or worse, like the flows moved somewhere + * else. Pass `hideEmpty` while a search or filter is narrowing the list, where an empty group + * would be noise instead. * - A flow whose projectId does not resolve to a known project counts as ungrouped: the backend * withholds project membership from non-owners, and a stale id must never hide a flow. */ -export function groupFlowsByProject(flows: Flow[], projects: Project[]): FlowGroupView[] { +export function groupFlowsByProject(flows: Flow[], projects: Project[], + options: { hideEmpty?: boolean } = {}): FlowGroupView[] { const byId = new Map(projects.map((project) => [project.id, project])); const groups = new Map(); + if (!options.hideEmpty) { + for (const project of projects) { + groups.set(project.id, { key: project.id, project, flows: [] }); + } + } + for (const flow of flows) { const project = flow.projectId ? byId.get(flow.projectId) ?? null : null; const key = project ? project.id : UNGROUPED_PROJECT_KEY; @@ -35,7 +44,10 @@ export function groupFlowsByProject(flows: Flow[], projects: Project[]): FlowGro } } - return [...groups.values()].sort(compareGroups); + // The ungrouped bucket only ever exists when something is actually in it. + return [...groups.values()] + .filter((group) => group.flows.length > 0 || group.project !== null) + .sort(compareGroups); } function compareGroups(a: FlowGroupView, b: FlowGroupView): number { diff --git a/src/app/shared/flows-list/flows-group/flows-group.css b/src/app/shared/flows-list/flows-group/flows-group.css index 19f3c2e..24da283 100644 --- a/src/app/shared/flows-list/flows-group/flows-group.css +++ b/src/app/shared/flows-list/flows-group/flows-group.css @@ -102,3 +102,10 @@ height: 16px; line-height: 16px; } + +.flows-list-group-empty { + padding: 0.25rem; + color: #94a3b8; + font-size: 0.8125rem; + font-style: italic; +} diff --git a/src/app/shared/flows-list/flows-group/flows-group.html b/src/app/shared/flows-list/flows-group/flows-group.html index 4d24aae..5e27c91 100644 --- a/src/app/shared/flows-list/flows-group/flows-group.html +++ b/src/app/shared/flows-list/flows-group/flows-group.html @@ -64,6 +64,11 @@ @if (expanded()) {
+ @if (!flows().length) { +
+ No flows yet. Use “Move to project” on a flow to put it here. +
+ } @for (flow of flows(); track flow.id) {
@if (canReorder()) { diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts index b11ec09..052bea6 100644 --- a/src/app/shared/flows-list/flows-list.ts +++ b/src/app/shared/flows-list/flows-list.ts @@ -176,7 +176,9 @@ export class FlowsList extends ListStateViewHolder { PROJECTS_ENABLED && this.projects().length > 0 && this.projectFilter() === ALL_PROJECTS); /** Flows already filtered and sorted, then grouped: the existing pipeline is untouched. */ - readonly groupedFlows = computed(() => groupFlowsByProject(this.orderedFlows(), this.projects())); + readonly groupedFlows = computed(() => groupFlowsByProject(this.orderedFlows(), this.projects(), + // While the list is being narrowed, an empty group is noise rather than reassurance. + { hideEmpty: this.searchActive() || this.filter() !== 'all' })); readonly searchActive = computed(() => this.searchTerm().trim().length > 0); diff --git a/src/app/shared/project-dialog/project-dialog.spec.ts b/src/app/shared/project-dialog/project-dialog.spec.ts new file mode 100644 index 0000000..1e3355f --- /dev/null +++ b/src/app/shared/project-dialog/project-dialog.spec.ts @@ -0,0 +1,26 @@ +import { TestBed } from '@angular/core/testing'; +import { ProjectDialogService } from '@services/dialogs/project-dialog'; +import { ProjectDialogComponent } from './project-dialog'; + +describe('ProjectDialogComponent name capture', () => { + it('resolves the typed name', async () => { + await TestBed.configureTestingModule({ imports: [ProjectDialogComponent] }).compileComponents(); + const dialog = TestBed.inject(ProjectDialogService); + const fixture = TestBed.createComponent(ProjectDialogComponent); + fixture.detectChanges(); + + const result = dialog.open({ project: null }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); + input.value = 'Recruiting'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.submit(); + await expect(result).resolves.toEqual({ name: 'Recruiting', description: '' }); + }); +});