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: '' }); + }); +});