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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 08:02:47 +02:00
parent 0b706ea95c
commit 3eba5815ee
7 changed files with 84 additions and 12 deletions

View File

@ -57,7 +57,9 @@ export class ProjectsCallServiceFake extends ProjectsCallServiceBase {
override createProject(draft: ProjectDraft): Observable<Project> {
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,

View File

@ -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);

View File

@ -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<string, FlowGroupView>();
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 {

View File

@ -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;
}

View File

@ -64,6 +64,11 @@
@if (expanded()) {
<div class="flows-list-group-flows">
@if (!flows().length) {
<div class="flows-list-group-empty">
No flows yet. Use “Move to project” on a flow to put it here.
</div>
}
@for (flow of flows(); track flow.id) {
<div class="flows-list-group-flow">
@if (canReorder()) {

View File

@ -176,7 +176,9 @@ export class FlowsList extends ListStateViewHolder<Flow> {
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);

View File

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