Group the flows list by project, with shared context and project runs
Projects group the flow list in place: no new page or tab, the existing sidebar
list gains collapsible per-project sections plus a project filter. Everything is
behind PROJECTS_ENABLED in shared/feature-flags, and with no projects the list
renders exactly as it did before - flows-list.spec guards that.
FlowsList stays the single owner of the load, filter, sort and list state; the
grouping is extracted into a pure flow-grouping.ts and a presentational
flows-group component. A wrapper rendering N flows lists would have re-registered
the same list state and re-triggered the load N times. The group chrome mirrors
tasks-executions-list, which already implements collapsible groups, so the two
sidebars read as one product.
toFlowCreateRequest deliberately still carries no project. It builds the
full-replace PUT the editor issues on every save, so a project sent there would
be silently dropped each time; membership changes only through
assignFlowToProject. flow-mapper.spec guards it.
Deleting a project destroys its flows, so it gets its own dialog rather than a
wider ConfirmDialogService: it names the count, lists the flows, says that
finalized flows go too - which the flow list otherwise forbids - and requires the
project name to be typed. Widening the shared confirm service for one destructive
caller would have rippled through every other call site.
Moving a flow between projects is a menu item, not drag-and-drop: the sidebar is
320px with its own scroll, and the whole card is already a click target, so a
drag gesture would fight the open-flow gesture. Flow order inside a project uses
up/down arrows for the same reason.
Shared context is edited in a dialog modelled on the Global Inputs panel, which
is the mental model users already have for ${{global.x}}; the title toolbar shows
the inherited values read-only, because that is where prompts are written.
"Run project" creates and starts a run - creating alone would look like nothing
happened. Runs come back BLOCKED or STOPPED when a step needs inputs or failed,
and the UI says so instead of claiming progress. Project runs surface in /tasks
as sibling groups labelled with a project chip, derived in tasks-executor: no
second nesting level and no backend change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
abf21b1c24
commit
0b706ea95c
|
|
@ -7,3 +7,6 @@
|
|||
<app-bias-impact-experiment-dialog-host></app-bias-impact-experiment-dialog-host>
|
||||
<app-bias-rerun-dialog-host></app-bias-rerun-dialog-host>
|
||||
<app-bias-compare-dialog-host></app-bias-compare-dialog-host>
|
||||
<app-project-dialog></app-project-dialog>
|
||||
<app-project-delete-dialog></app-project-delete-dialog>
|
||||
<app-project-context-dialog></app-project-context-dialog>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialo
|
|||
import { BiasImpactExperimentDialogHostComponent } from '@shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog';
|
||||
import { BiasRerunDialogHostComponent } from '@shared/bias-rerun-dialog/bias-rerun-dialog';
|
||||
import { BiasCompareDialogHostComponent } from '@shared/bias-compare-dialog/bias-compare-dialog';
|
||||
import { ProjectContextDialogComponent } from '@shared/project-context-dialog/project-context-dialog';
|
||||
import { ProjectDeleteDialogComponent } from '@shared/project-delete-dialog/project-delete-dialog';
|
||||
import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent],
|
||||
imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { BlocksService } from '@services/blocks/blocks';
|
|||
import { ContainersService } from '@services/containers/containers';
|
||||
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { catchError, EMPTY, exhaustMap, timer } from 'rxjs';
|
||||
|
||||
export type InteractiveSubflowTarget = {
|
||||
|
|
@ -73,6 +75,8 @@ export class TasksExecutor {
|
|||
private taskExecutionsService = inject(TaskExecutionsService);
|
||||
private confirm = inject(ConfirmDialogService);
|
||||
private blocksService = inject(BlocksService);
|
||||
private flowsService = inject(FlowsService);
|
||||
private projectsService = inject(ProjectsService);
|
||||
private containersService = inject(ContainersService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
|
@ -154,6 +158,13 @@ export class TasksExecutor {
|
|||
void this.containersService.getAllContainerTypes().catch((err) => {
|
||||
console.error('Error preloading container types for task executor', err);
|
||||
});
|
||||
// Needed only to label a group with its project; failure just leaves the chip off.
|
||||
void this.flowsService.getAllFlows().catch((err) => {
|
||||
console.error('Error preloading flows for task executor', err);
|
||||
});
|
||||
void this.projectsService.getAllProjects().catch((err) => {
|
||||
console.error('Error preloading projects for task executor', err);
|
||||
});
|
||||
this.taskExecutionsService.init();
|
||||
effect(() => {
|
||||
const routeSelectedId = this.routeExecutionId().get('executionId');
|
||||
|
|
@ -291,10 +302,19 @@ export class TasksExecutor {
|
|||
latestExecutionId: group.latestExecutionId || latestExecution?.id || '',
|
||||
latestStatus: latestExecution?.status ?? 'CREATED',
|
||||
latestRunNumber: latestExecution?.runNumber ?? null,
|
||||
projectName: this.projectNameForFlow(group.sourceFlowId),
|
||||
executions
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves source flow -> project -> name; null when the flow has no project. */
|
||||
private projectNameForFlow(sourceFlowId: string | null | undefined): string | null {
|
||||
if (!sourceFlowId) return null;
|
||||
const flow = this.flowsService.flows().find((candidate) => candidate.id === sourceFlowId);
|
||||
if (!flow?.projectId) return null;
|
||||
return this.projectsService.projectById().get(flow.projectId)?.name ?? flow.projectName ?? null;
|
||||
}
|
||||
|
||||
private toExecutionListItem(execution: TaskExecution, fallbackRunNumber: number): TaskExecutionListItem {
|
||||
return {
|
||||
id: execution.id,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ export type Flow = {
|
|||
updatedAt: Date;
|
||||
published?: boolean;
|
||||
finalized?: boolean;
|
||||
/**
|
||||
* Owning project, or undefined when the flow belongs to none. The backend discloses this only
|
||||
* to the flow's owner, so another user's published flow always reads as unassigned.
|
||||
*/
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
validationErrors?: FlowValidationError[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
/** The value types a project shared-context entry can hold - mirrors the backend IOType enum. */
|
||||
export type ProjectContextEntryType = 'TEXT' | 'BOOLEAN' | 'FILE' | 'CSV' | 'JSON';
|
||||
|
||||
/**
|
||||
* One shared value a project hands to its flows. The `name` is what a flow author writes as
|
||||
* `${{project.<name>}}`, so it must be a valid placeholder name and must not start with a
|
||||
* reserved prefix (`project.`, `global.`, `context.`, `vars.`) - the backend enforces both.
|
||||
*/
|
||||
export type ProjectContextEntry = {
|
||||
name: string;
|
||||
type: ProjectContextEntryType;
|
||||
multiple: boolean;
|
||||
value: unknown;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectContext = {
|
||||
entries: ProjectContextEntry[];
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
owner: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
sharedContext: ProjectContext;
|
||||
/** Only present on the listing shape, which never carries the flows themselves. */
|
||||
flowCount?: number;
|
||||
};
|
||||
|
||||
export type ProjectDraft = Pick<Project, 'name'> & { description?: string };
|
||||
|
||||
/** Key used for the pseudo-group holding flows that belong to no project. */
|
||||
export const UNGROUPED_PROJECT_KEY = '__ungrouped__';
|
||||
|
||||
const CONTEXT_ENTRY_TYPES: ProjectContextEntryType[] = ['TEXT', 'BOOLEAN', 'FILE', 'CSV', 'JSON'];
|
||||
|
||||
export function normalizeSharedContext(raw: unknown): ProjectContext {
|
||||
const value = (raw ?? {}) as Record<string, unknown>;
|
||||
const rawEntries = Array.isArray(value['entries']) ? value['entries'] : [];
|
||||
|
||||
const entries = rawEntries
|
||||
.filter((entry): entry is Record<string, unknown> =>
|
||||
!!entry && typeof entry === 'object' && !Array.isArray(entry))
|
||||
.map((entry) => {
|
||||
const type = String(entry['type'] ?? 'TEXT').toUpperCase() as ProjectContextEntryType;
|
||||
return {
|
||||
name: String(entry['name'] ?? ''),
|
||||
type: CONTEXT_ENTRY_TYPES.includes(type) ? type : 'TEXT',
|
||||
multiple: Boolean(entry['multiple']),
|
||||
value: entry['value'] ?? null,
|
||||
description: typeof entry['description'] === 'string' ? entry['description'] : null
|
||||
} satisfies ProjectContextEntry;
|
||||
})
|
||||
.filter((entry) => entry.name.length > 0);
|
||||
|
||||
return { entries };
|
||||
}
|
||||
|
||||
/** The placeholder a flow author writes to read this entry. */
|
||||
export function projectTemplateReference(name: string): string {
|
||||
return `\${{project.${name}}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived from the run's executions; the run keeps no state of its own.
|
||||
* BLOCKED = the next step still needs inputs or credentials. STOPPED = a step failed.
|
||||
*/
|
||||
export type ProjectRunStatus = 'PENDING' | 'RUNNING' | 'BLOCKED' | 'STOPPED' | 'COMPLETED';
|
||||
|
||||
/** One run of a project: the executions started together, tied by `projectRunId`. */
|
||||
export type ProjectRun = {
|
||||
projectRunId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
executionCount: number;
|
||||
status: ProjectRunStatus;
|
||||
currentExecutionId: string | null;
|
||||
completedCount: number;
|
||||
blockedReason: string | null;
|
||||
executionIds: string[];
|
||||
};
|
||||
|
||||
/** What a project run created, and the flows it deliberately left out. */
|
||||
export type ProjectExecutionPlan = {
|
||||
run: ProjectRun;
|
||||
skipped: { flowId: string; flowName: string; reason: string }[];
|
||||
};
|
||||
|
|
@ -45,6 +45,10 @@ export type TaskExecution = {
|
|||
creationTime: number;
|
||||
flowId?: string | null;
|
||||
sourceFlowId?: string | null;
|
||||
/** The project the source flow belonged to when the run was created. */
|
||||
projectId?: string | null;
|
||||
/** Ties together the executions started by one project run; null for a single-flow run. */
|
||||
projectRunId?: string | null;
|
||||
runNumber?: number | null;
|
||||
rerunOfExecutionId?: string | null;
|
||||
executionKind?: ExecutionKind;
|
||||
|
|
@ -90,6 +94,11 @@ export type TaskExecutionContext = {
|
|||
inputs: Record<string, unknown>;
|
||||
globalInputs?: Record<string, unknown>;
|
||||
globalInputDescriptors?: Record<string, TaskExecutionGlobalInputDescriptor>;
|
||||
/**
|
||||
* Values inherited from the flow's project, frozen when the run was created. Read in prompts as
|
||||
* `${{project.<name>}}`.
|
||||
*/
|
||||
projectContext?: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
partialResult?: Record<string, unknown>;
|
||||
startTime?: number | null;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@
|
|||
<mat-icon fontIcon="auto_awesome"></mat-icon>
|
||||
<span>Create with AI</span>
|
||||
</button>
|
||||
@if (projectsEnabled) {
|
||||
<button type="button" mat-menu-item class="editor-sidebar-new-flow-menu-item" (click)="createProject()">
|
||||
<mat-icon fontIcon="create_new_folder"></mat-icon>
|
||||
<span>New project</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
} @else {
|
||||
<button
|
||||
|
|
@ -86,7 +92,7 @@
|
|||
</button>
|
||||
}
|
||||
</span>
|
||||
<app-flows-list></app-flows-list>
|
||||
<app-flows-list #flowsList></app-flows-list>
|
||||
</app-group-holder>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, effect, inject, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { GroupHolder } from '@shared/group-holder/group-holder';
|
||||
import { FlowsList } from '@shared/flows-list/flows-list';
|
||||
import { BlocksList } from '@shared/blocks-list/blocks-list';
|
||||
|
|
@ -13,6 +13,7 @@ import { finalize } from 'rxjs';
|
|||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
|
||||
import { PROJECTS_ENABLED } from '@shared/feature-flags';
|
||||
|
||||
type OpenedId = 'flows' | 'blocks' | 'containers';
|
||||
|
||||
|
|
@ -34,6 +35,15 @@ export class EditorSidebar {
|
|||
containersService = inject(ContainersService);
|
||||
confirm = inject(ConfirmDialogService);
|
||||
|
||||
readonly projectsEnabled = PROJECTS_ENABLED;
|
||||
|
||||
/** The flows list owns the project dialogs, since it also owns the grouping they act on. */
|
||||
private readonly flowsList = viewChild(FlowsList);
|
||||
|
||||
createProject() {
|
||||
void this.flowsList()?.createProject();
|
||||
}
|
||||
|
||||
blockDisabled = computed(() => !this.flowState.hasFlow());
|
||||
containerDisabled = computed(() =>
|
||||
!this.flowState.hasFlow() || this.flowState.isEditingSubflow()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { Project, ProjectContext } from '@models/project';
|
||||
|
||||
type ProjectContextDialogState = {
|
||||
project: Project;
|
||||
resolve: (context: ProjectContext | null) => void;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectContextDialogService {
|
||||
private readonly _state = signal<ProjectContextDialogState | null>(null);
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
open(project: Project): Promise<ProjectContext | null> {
|
||||
return new Promise<ProjectContext | null>((resolve) => {
|
||||
this._state.set({ project, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
close(context: ProjectContext | null) {
|
||||
const state = this._state();
|
||||
if (!state) return;
|
||||
|
||||
state.resolve(context);
|
||||
this._state.set(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { Project } from '@models/project';
|
||||
|
||||
export type ProjectDeleteDialogInput = {
|
||||
project: Project;
|
||||
/**
|
||||
* The flows that will be destroyed, taken from the client cache rather than from the API count:
|
||||
* the user must be shown exactly what they can see in the sidebar.
|
||||
*/
|
||||
flows: { id: string; name: string; finalized?: boolean }[];
|
||||
};
|
||||
|
||||
type ProjectDeleteDialogState = ProjectDeleteDialogInput & {
|
||||
resolve: (confirmed: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A dedicated dialog rather than a wider ConfirmDialogService: that service's single-message
|
||||
* signature is used by many callers, and widening it for one destructive case would ripple through
|
||||
* every one of them.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectDeleteDialogService {
|
||||
private readonly _state = signal<ProjectDeleteDialogState | null>(null);
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
open(input: ProjectDeleteDialogInput): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this._state.set({ ...input, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
close(confirmed: boolean) {
|
||||
const state = this._state();
|
||||
if (!state) return;
|
||||
|
||||
state.resolve(confirmed);
|
||||
this._state.set(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Injectable, signal } from '@angular/core';
|
||||
import { Project, ProjectDraft } from '@models/project';
|
||||
|
||||
export type ProjectDialogInput = {
|
||||
/** Null creates a project; a project edits it. */
|
||||
project: Project | null;
|
||||
};
|
||||
|
||||
type ProjectDialogState = ProjectDialogInput & {
|
||||
resolve: (draft: ProjectDraft | null) => void;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectDialogService {
|
||||
private readonly _state = signal<ProjectDialogState | null>(null);
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
open(input: ProjectDialogInput): Promise<ProjectDraft | null> {
|
||||
return new Promise<ProjectDraft | null>((resolve) => {
|
||||
this._state.set({ ...input, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
close(draft: ProjectDraft | null) {
|
||||
const state = this._state();
|
||||
if (!state) return;
|
||||
|
||||
state.resolve(draft);
|
||||
this._state.set(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,3 +76,40 @@ describe('flow-mapper', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('flowFromApi and toFlowCreateRequest project membership', () => {
|
||||
it('reads projectId and projectName from the API shape', () => {
|
||||
const flow = flowFromApi({
|
||||
id: 'f1',
|
||||
name: 'Flow',
|
||||
projectId: 'p1',
|
||||
projectName: 'Recruiting'
|
||||
});
|
||||
|
||||
expect(flow.projectId).toBe('p1');
|
||||
expect(flow.projectName).toBe('Recruiting');
|
||||
});
|
||||
|
||||
it('also tolerates a nested project object', () => {
|
||||
const flow = flowFromApi({ id: 'f1', name: 'Flow', project: { id: 'p2', name: 'Reporting' } });
|
||||
|
||||
expect(flow.projectId).toBe('p2');
|
||||
expect(flow.projectName).toBe('Reporting');
|
||||
});
|
||||
|
||||
it('leaves project membership undefined when the API withholds it', () => {
|
||||
// A non-owner reading a published flow gets no project, and it must read as unassigned.
|
||||
const flow = flowFromApi({ id: 'f1', name: 'Flow' });
|
||||
|
||||
expect(flow.projectId).toBeUndefined();
|
||||
expect(flow.projectName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never sends a project in the update body', () => {
|
||||
// This body is a full replace used by every editor save. Carrying projectId here - or worse,
|
||||
// omitting it while the backend honoured it - would silently detach the flow on every save.
|
||||
const body = toFlowCreateRequest('Flow', 'desc', undefined, 'DRAFT') as Record<string, unknown>;
|
||||
|
||||
expect('projectId' in body).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export function flowFromApi(raw: unknown): Flow {
|
|||
? value['published']
|
||||
: ((value['visibility'] as FlowVisibility | undefined) === 'PUBLIC');
|
||||
const visibility: FlowVisibility = published ? 'PUBLIC' : 'PRIVATE';
|
||||
const project = (value['project'] ?? null) as Record<string, unknown> | null;
|
||||
const projectId = typeof value['projectId'] === 'string'
|
||||
? value['projectId']
|
||||
: (project && typeof project['id'] === 'string' ? project['id'] : undefined);
|
||||
const projectName = typeof value['projectName'] === 'string'
|
||||
? value['projectName']
|
||||
: (project && typeof project['name'] === 'string' ? project['name'] : undefined);
|
||||
const rawStatus = typeof value['status'] === 'string' ? value['status'].toUpperCase() : null;
|
||||
const status: FlowStatus = rawStatus === 'EXECUTABLE' ? 'EXECUTABLE' : 'DRAFT';
|
||||
|
||||
|
|
@ -30,6 +37,8 @@ export function flowFromApi(raw: unknown): Flow {
|
|||
visibility,
|
||||
published,
|
||||
finalized: typeof value['finalized'] === 'boolean' ? value['finalized'] : undefined,
|
||||
projectId,
|
||||
projectName,
|
||||
validationErrors: normalizeFlowValidationErrors(value['validationErrors'] ?? value['errors']),
|
||||
data: {
|
||||
blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[],
|
||||
|
|
@ -42,6 +51,11 @@ export function flowFromApi(raw: unknown): Flow {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately omits projectId. This is the body of the full-replace PUT the editor issues on
|
||||
* every save, so carrying a project here would silently detach a flow whenever the field was
|
||||
* missing. Membership is changed only through FlowsCallService.assignFlowToProject.
|
||||
*/
|
||||
export function toFlowCreateRequest(name: string, description?: string, flow?: FlowData, status: FlowStatus = 'DRAFT') {
|
||||
return {
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -23,4 +23,11 @@ export abstract class FlowsCallServiceBase {
|
|||
|
||||
abstract getGroupedFlowValidation(flowId: string) : Observable<GroupedFlowValidation>;
|
||||
|
||||
/**
|
||||
* Moves a flow into a project, or detaches it when projectId is null. Its own endpoint, not a
|
||||
* field on the update body: that body is a full replace, so a project carried there would be
|
||||
* lost on every editor save. Works on finalized flows - membership is metadata, not content.
|
||||
*/
|
||||
abstract assignFlowToProject(flowId: string, projectId: string | null) : Observable<Flow>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,22 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
|
|||
authorizationService = inject(Authorization);
|
||||
|
||||
private data: Record<string, Flow> = {
|
||||
'1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") },
|
||||
'2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") },
|
||||
'1': { id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00"), projectId: 'p1', projectName: 'Recruiting' },
|
||||
'2': { id: '2', name: 'Test Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00"), projectId: 'p2', projectName: 'Reporting' },
|
||||
'testFlow': flowFromApi(testDataFlow)
|
||||
}
|
||||
|
||||
override assignFlowToProject(flowId: string, projectId: string | null) {
|
||||
return defer(() => {
|
||||
const flow = this.requireFlow(flowId);
|
||||
this.requireOwner(flow);
|
||||
// No finalized check on purpose: membership stays changeable on a finalized flow.
|
||||
const updated: Flow = { ...flow, projectId: projectId ?? undefined, updatedAt: new Date() };
|
||||
this.data[flowId] = updated;
|
||||
return of(updated);
|
||||
});
|
||||
}
|
||||
|
||||
override retrieveAllFlows() {
|
||||
return of(Object.values(this.data));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ export class FlowsCallService extends FlowsCallServiceBase {
|
|||
.pipe(map((raw) => flowFromApi(raw)));
|
||||
}
|
||||
|
||||
override assignFlowToProject(flowId: string, projectId: string | null): Observable<Flow> {
|
||||
const encodedId = encodeURIComponent(flowId);
|
||||
return this.http
|
||||
.put<unknown>(`${environment.apiUrl}/flows/${encodedId}/project`, { projectId })
|
||||
.pipe(map((raw) => flowFromApi(raw)));
|
||||
}
|
||||
|
||||
override updatePublished(flowId: string, value: boolean): Observable<Flow> {
|
||||
const encodedId = encodeURIComponent(flowId);
|
||||
return this.http
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Injectable, signal } from '@angular/core';
|
|||
import { environment } from '@environment';
|
||||
import { Flow } from '@models/flow';
|
||||
import { FlowsCallServiceBase } from './flows-call.base';
|
||||
import { catchError, firstValueFrom, Observable, of, tap, throwError } from 'rxjs';
|
||||
import { catchError, firstValueFrom, Observable, of, switchMap, tap, throwError } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -133,6 +133,28 @@ export class FlowsService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a flow into a project, or detaches it when projectId is null. Patches the cache in place
|
||||
* so the sidebar re-groups reactively without a refetch.
|
||||
*/
|
||||
assignFlowToProject(flowId: string, projectId: string | null) {
|
||||
return this.flowsCallService.assignFlowToProject(flowId, projectId).pipe(
|
||||
tap((updatedFlow) => {
|
||||
this._flows.update((flows) => {
|
||||
const index = flows.findIndex((current) => current.id === updatedFlow.id);
|
||||
if (index < 0) return [updatedFlow, ...flows];
|
||||
const next = [...flows];
|
||||
next[index] = updatedFlow;
|
||||
return next;
|
||||
});
|
||||
}),
|
||||
catchError(err => {
|
||||
console.error('Assign flow to project failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
finalizeFlow(flowId: string) {
|
||||
return this.flowsCallService.finalizeFlow(flowId).pipe(
|
||||
tap((updatedFlow) => {
|
||||
|
|
@ -169,13 +191,18 @@ export class FlowsService {
|
|||
);
|
||||
}
|
||||
|
||||
cloneFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
|
||||
cloneFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'> & Pick<Partial<Flow>, 'projectId'>): Observable<Flow> {
|
||||
return this.createFlow({
|
||||
name: `${flow.name} (cloned)`,
|
||||
description: flow.description,
|
||||
data: flow.data,
|
||||
status: flow.status
|
||||
}).pipe(
|
||||
// A clone is made from inside a group, so it belongs there too. Creation cannot carry the
|
||||
// project - that is what keeps it out of the full-replace body - so assign it afterwards.
|
||||
switchMap((created) => flow.projectId
|
||||
? this.assignFlowToProject(created.id, flow.projectId)
|
||||
: of(created)),
|
||||
catchError(err => {
|
||||
console.error('Cloning flow failed', err);
|
||||
return throwError(() => err);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import { projectFromApi, toProjectContextRequest, toProjectRequest } from './project-mapper';
|
||||
|
||||
describe('project-mapper', () => {
|
||||
describe('projectFromApi', () => {
|
||||
it('maps the backend shape, including lastUpdateAt', () => {
|
||||
const project = projectFromApi({
|
||||
id: 'p1',
|
||||
name: 'Recruiting',
|
||||
description: 'Screening',
|
||||
owner: 'alice',
|
||||
createdAt: '2026-01-05T09:00:00',
|
||||
lastUpdateAt: '2026-01-09T11:30:00',
|
||||
flowCount: 3,
|
||||
sharedContext: { entries: [{ name: 'tone', type: 'text', value: 'formal' }] }
|
||||
});
|
||||
|
||||
expect(project.id).toBe('p1');
|
||||
expect(project.name).toBe('Recruiting');
|
||||
expect(project.owner).toBe('alice');
|
||||
expect(project.flowCount).toBe(3);
|
||||
expect(project.updatedAt.getFullYear()).toBe(2026);
|
||||
expect(project.sharedContext.entries).toEqual([
|
||||
{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal', description: null }
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to createdAt when no update timestamp is present', () => {
|
||||
const project = projectFromApi({ id: 'p1', name: 'P', createdAt: '2026-02-01T00:00:00' });
|
||||
|
||||
expect(project.updatedAt.getTime()).toBe(project.createdAt.getTime());
|
||||
});
|
||||
|
||||
it('defaults a missing shared context to no entries', () => {
|
||||
expect(projectFromApi({ id: 'p1', name: 'P' }).sharedContext).toEqual({ entries: [] });
|
||||
});
|
||||
|
||||
it('drops context entries without a name, which could never be referenced', () => {
|
||||
const project = projectFromApi({
|
||||
id: 'p1',
|
||||
name: 'P',
|
||||
sharedContext: { entries: [{ type: 'TEXT', value: 'x' }, { name: 'ok', type: 'TEXT' }] }
|
||||
});
|
||||
|
||||
expect(project.sharedContext.entries.map((entry) => entry.name)).toEqual(['ok']);
|
||||
});
|
||||
|
||||
it('falls back to TEXT for an unknown entry type', () => {
|
||||
const project = projectFromApi({
|
||||
id: 'p1',
|
||||
name: 'P',
|
||||
sharedContext: { entries: [{ name: 'x', type: 'SOMETHING_ELSE' }] }
|
||||
});
|
||||
|
||||
expect(project.sharedContext.entries[0].type).toBe('TEXT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request bodies', () => {
|
||||
it('sends an empty description rather than undefined', () => {
|
||||
expect(toProjectRequest({ name: 'P' })).toEqual({ name: 'P', description: '' });
|
||||
});
|
||||
|
||||
it('serializes context entries with an explicit null description', () => {
|
||||
const body = toProjectContextRequest({
|
||||
entries: [{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal' }]
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
entries: [{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal', description: null }]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Project, ProjectContext, ProjectDraft, normalizeSharedContext } from '@models/project';
|
||||
|
||||
function parseDate(value: unknown, fallback: Date): Date {
|
||||
if (typeof value !== 'string' || !value) return fallback;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||
}
|
||||
|
||||
export function projectFromApi(raw: unknown): Project {
|
||||
const value = (raw ?? {}) as Record<string, unknown>;
|
||||
const now = new Date();
|
||||
const createdAt = parseDate(value['createdAt'], now);
|
||||
|
||||
return {
|
||||
id: String(value['id'] ?? ''),
|
||||
name: String(value['name'] ?? 'Untitled Project'),
|
||||
description: typeof value['description'] === 'string' ? value['description'] : undefined,
|
||||
owner: String(value['owner'] ?? 'unknown'),
|
||||
createdAt,
|
||||
// The backend field is lastUpdateAt; tolerate updatedAt the way flowFromApi does.
|
||||
updatedAt: parseDate(value['updatedAt'] ?? value['lastUpdateAt'], createdAt),
|
||||
sharedContext: normalizeSharedContext(value['sharedContext']),
|
||||
flowCount: typeof value['flowCount'] === 'number' ? value['flowCount'] : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function toProjectRequest(draft: ProjectDraft) {
|
||||
return {
|
||||
name: draft.name,
|
||||
description: draft.description ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export function toProjectContextRequest(context: ProjectContext) {
|
||||
return {
|
||||
entries: context.entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
multiple: entry.multiple,
|
||||
value: entry.value,
|
||||
description: entry.description ?? null
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Project, ProjectContext, ProjectDraft } from '@models/project';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class ProjectsCallServiceBase {
|
||||
|
||||
abstract retrieveAllProjects(): Observable<Project[]>;
|
||||
|
||||
abstract getProjectById(projectId: string): Observable<Project>;
|
||||
|
||||
abstract createProject(draft: ProjectDraft): Observable<Project>;
|
||||
|
||||
abstract updateProject(projectId: string, draft: ProjectDraft): Observable<Project>;
|
||||
|
||||
abstract updateSharedContext(projectId: string, context: ProjectContext): Observable<Project>;
|
||||
|
||||
/** Sets the order a project run executes the flows in. */
|
||||
abstract updateFlowOrder(projectId: string, flowIds: string[]): Observable<Project>;
|
||||
|
||||
/**
|
||||
* Deletes the project and every flow in it, finalized flows included. Without `confirm` the
|
||||
* backend refuses a non-empty project with 409 rather than destroying anything.
|
||||
*/
|
||||
abstract deleteProject(projectId: string, confirm: boolean): Observable<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { inject } from '@angular/core';
|
||||
import { Project, ProjectContext, ProjectDraft } from '@models/project';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { defer, Observable, of } from 'rxjs';
|
||||
import { ProjectsCallServiceBase } from './projects-call.base';
|
||||
|
||||
export class ProjectsCallServiceFake extends ProjectsCallServiceBase {
|
||||
authorizationService = inject(Authorization);
|
||||
|
||||
/**
|
||||
* Seeded so that flows '1' and '2' from FlowsCallServiceFake land in a project while 'testFlow'
|
||||
* stays outside one: that exercises both the grouped and the ungrouped rendering paths in dev.
|
||||
*/
|
||||
private data: Record<string, Project> = {
|
||||
'p1': {
|
||||
id: 'p1',
|
||||
name: 'Recruiting',
|
||||
description: 'Candidate screening flows',
|
||||
owner: 'Alice',
|
||||
createdAt: new Date('January 5, 2026 09:00:00'),
|
||||
updatedAt: new Date('January 9, 2026 11:30:00'),
|
||||
sharedContext: {
|
||||
entries: [
|
||||
{ name: 'tone', type: 'TEXT', multiple: false, value: 'formal', description: 'Writing tone' }
|
||||
]
|
||||
}
|
||||
},
|
||||
'p2': {
|
||||
id: 'p2',
|
||||
name: 'Reporting',
|
||||
owner: 'Bob',
|
||||
createdAt: new Date('February 2, 2026 14:00:00'),
|
||||
updatedAt: new Date('February 3, 2026 08:15:00'),
|
||||
sharedContext: { entries: [] }
|
||||
}
|
||||
};
|
||||
|
||||
private ownerUsername(): string {
|
||||
return this.authorizationService.loggedInUser()?.username ?? '';
|
||||
}
|
||||
|
||||
private requireProject(projectId: string): Project {
|
||||
const project = this.data[projectId];
|
||||
if (!project) {
|
||||
throw new Error(`Project with id ${projectId} not found`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
override retrieveAllProjects(): Observable<Project[]> {
|
||||
return of(Object.values(this.data));
|
||||
}
|
||||
|
||||
override getProjectById(projectId: string): Observable<Project> {
|
||||
return defer(() => of(this.requireProject(projectId)));
|
||||
}
|
||||
|
||||
override createProject(draft: ProjectDraft): Observable<Project> {
|
||||
return defer(() => {
|
||||
const id = `p${Object.keys(this.data).length + 1}`;
|
||||
const now = new Date();
|
||||
const created: Project = {
|
||||
id,
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
owner: this.ownerUsername(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sharedContext: { entries: [] },
|
||||
flowCount: 0
|
||||
};
|
||||
this.data[id] = created;
|
||||
return of(created);
|
||||
});
|
||||
}
|
||||
|
||||
override updateProject(projectId: string, draft: ProjectDraft): Observable<Project> {
|
||||
return defer(() => {
|
||||
const updated: Project = {
|
||||
...this.requireProject(projectId),
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
this.data[projectId] = updated;
|
||||
return of(updated);
|
||||
});
|
||||
}
|
||||
|
||||
override updateSharedContext(projectId: string, context: ProjectContext): Observable<Project> {
|
||||
return defer(() => {
|
||||
const updated: Project = {
|
||||
...this.requireProject(projectId),
|
||||
sharedContext: context,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
this.data[projectId] = updated;
|
||||
return of(updated);
|
||||
});
|
||||
}
|
||||
|
||||
override updateFlowOrder(projectId: string, _flowIds: string[]): Observable<Project> {
|
||||
return defer(() => of(this.requireProject(projectId)));
|
||||
}
|
||||
|
||||
override deleteProject(projectId: string, confirm: boolean): Observable<void> {
|
||||
return defer(() => {
|
||||
this.requireProject(projectId);
|
||||
if (!confirm) {
|
||||
throw new Error('Project deletion must be confirmed.');
|
||||
}
|
||||
delete this.data[projectId];
|
||||
return of(undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { Project, ProjectContext, ProjectDraft } from '@models/project';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { projectFromApi, toProjectContextRequest, toProjectRequest } from './project-mapper';
|
||||
import { ProjectsCallServiceBase } from './projects-call.base';
|
||||
|
||||
export class ProjectsCallService extends ProjectsCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
override retrieveAllProjects(): Observable<Project[]> {
|
||||
return this.http
|
||||
.get<unknown[]>(`${environment.apiUrl}/projects`)
|
||||
.pipe(map((raw) => raw.map((project) => projectFromApi(project))));
|
||||
}
|
||||
|
||||
override getProjectById(projectId: string): Observable<Project> {
|
||||
return this.http
|
||||
.get<unknown>(`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}`)
|
||||
.pipe(map((raw) => projectFromApi(raw)));
|
||||
}
|
||||
|
||||
override createProject(draft: ProjectDraft): Observable<Project> {
|
||||
return this.http
|
||||
.post<unknown>(`${environment.apiUrl}/projects`, toProjectRequest(draft))
|
||||
.pipe(map((raw) => projectFromApi(raw)));
|
||||
}
|
||||
|
||||
override updateProject(projectId: string, draft: ProjectDraft): Observable<Project> {
|
||||
return this.http
|
||||
.put<unknown>(`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}`, toProjectRequest(draft))
|
||||
.pipe(map((raw) => projectFromApi(raw)));
|
||||
}
|
||||
|
||||
override updateSharedContext(projectId: string, context: ProjectContext): Observable<Project> {
|
||||
return this.http
|
||||
.put<unknown>(
|
||||
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/context`,
|
||||
toProjectContextRequest(context)
|
||||
)
|
||||
.pipe(map((raw) => projectFromApi(raw)));
|
||||
}
|
||||
|
||||
override updateFlowOrder(projectId: string, flowIds: string[]): Observable<Project> {
|
||||
return this.http
|
||||
.put<unknown>(`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/flow-order`, { flowIds })
|
||||
.pipe(map((raw) => projectFromApi(raw)));
|
||||
}
|
||||
|
||||
override deleteProject(projectId: string, confirm: boolean): Observable<void> {
|
||||
return this.http.delete<void>(
|
||||
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}?confirm=${confirm}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { Project } from '@models/project';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { ProjectsCallServiceBase } from './projects-call.base';
|
||||
import { ProjectsService } from './projects';
|
||||
|
||||
function makeProject(id: string, name: string): Project {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
owner: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
sharedContext: { entries: [] }
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProjectsService', () => {
|
||||
let service: ProjectsService;
|
||||
let callServiceSpy: {
|
||||
retrieveAllProjects: ReturnType<typeof vi.fn>;
|
||||
getProjectById: ReturnType<typeof vi.fn>;
|
||||
createProject: ReturnType<typeof vi.fn>;
|
||||
updateProject: ReturnType<typeof vi.fn>;
|
||||
updateSharedContext: ReturnType<typeof vi.fn>;
|
||||
deleteProject: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
callServiceSpy = {
|
||||
retrieveAllProjects: vi.fn(),
|
||||
getProjectById: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
updateSharedContext: vi.fn(),
|
||||
deleteProject: vi.fn()
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ProjectsService);
|
||||
service.projectsCallService = callServiceSpy as unknown as ProjectsCallServiceBase;
|
||||
service.toInit = true;
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loads projects once and exposes them as a signal', async () => {
|
||||
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Recruiting')]));
|
||||
|
||||
await service.getAllProjects();
|
||||
await service.getAllProjects();
|
||||
|
||||
expect(callServiceSpy.retrieveAllProjects).toHaveBeenCalledTimes(1);
|
||||
expect(service.projects().map((project) => project.id)).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('stays empty and reports not loaded when the endpoint fails', async () => {
|
||||
// The flows list falls back to the flat rendering on this path: projects must never be able
|
||||
// to break the flows.
|
||||
callServiceSpy.retrieveAllProjects.mockReturnValue(throwError(() => new Error('boom')));
|
||||
|
||||
await service.getAllProjects();
|
||||
|
||||
expect(service.projects()).toEqual([]);
|
||||
expect(service.hasLoadedProjects()).toBe(false);
|
||||
});
|
||||
|
||||
it('indexes projects by id for O(1) lookup from a flow', async () => {
|
||||
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Recruiting')]));
|
||||
await service.getAllProjects();
|
||||
|
||||
expect(service.projectById().get('p1')?.name).toBe('Recruiting');
|
||||
});
|
||||
|
||||
it('prepends a created project to the cache', async () => {
|
||||
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'One')]));
|
||||
await service.getAllProjects();
|
||||
callServiceSpy.createProject.mockReturnValue(of(makeProject('p2', 'Two')));
|
||||
|
||||
await new Promise((resolve) => service.createProject({ name: 'Two' }).subscribe(resolve));
|
||||
|
||||
expect(service.projects().map((project) => project.id)).toEqual(['p2', 'p1']);
|
||||
});
|
||||
|
||||
it('replaces an updated project in place', async () => {
|
||||
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Old')]));
|
||||
await service.getAllProjects();
|
||||
callServiceSpy.updateProject.mockReturnValue(of(makeProject('p1', 'New')));
|
||||
|
||||
await new Promise((resolve) => service.updateProject('p1', { name: 'New' }).subscribe(resolve));
|
||||
|
||||
expect(service.projects()).toHaveLength(1);
|
||||
expect(service.projects()[0].name).toBe('New');
|
||||
});
|
||||
|
||||
it('drops a deleted project from the cache and confirms by default', async () => {
|
||||
callServiceSpy.retrieveAllProjects.mockReturnValue(of([makeProject('p1', 'Doomed')]));
|
||||
await service.getAllProjects();
|
||||
callServiceSpy.deleteProject.mockReturnValue(of(undefined));
|
||||
|
||||
await new Promise((resolve) => service.deleteProject('p1').subscribe(resolve));
|
||||
|
||||
expect(service.projects()).toEqual([]);
|
||||
expect(callServiceSpy.deleteProject).toHaveBeenCalledWith('p1', true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { Project, ProjectContext, ProjectDraft } from '@models/project';
|
||||
import { catchError, firstValueFrom, Observable, of, tap, throwError } from 'rxjs';
|
||||
import { ProjectsCallServiceBase } from './projects-call.base';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ProjectsService {
|
||||
|
||||
projectsCallService: ProjectsCallServiceBase = new environment.projectsCallService();
|
||||
|
||||
toInit: boolean = true;
|
||||
private loadingPromise: Promise<void> | null = null;
|
||||
|
||||
private _projects = signal<Project[]>([]);
|
||||
readonly projects = this._projects.asReadonly();
|
||||
|
||||
/** Lets a flow resolve its project in O(1) without threading inputs through the tree. */
|
||||
readonly projectById = computed(() => new Map(this._projects().map((project) => [project.id, project])));
|
||||
|
||||
hasLoadedProjects() {
|
||||
return this._projects().length > 0 || !this.toInit;
|
||||
}
|
||||
|
||||
async getAllProjects() {
|
||||
if (this.toInit) {
|
||||
this.toInit = false;
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch {
|
||||
this.toInit = true;
|
||||
}
|
||||
}
|
||||
|
||||
return this.projects;
|
||||
}
|
||||
|
||||
getProjectById(projectId: string, forceRefresh = false): Observable<Project> {
|
||||
const cached = this._projects().find((project) => project.id === projectId);
|
||||
if (cached && !forceRefresh) return of(cached);
|
||||
|
||||
return this.projectsCallService.getProjectById(projectId).pipe(
|
||||
tap((project) => this.patch(project))
|
||||
);
|
||||
}
|
||||
|
||||
async refresh(force = false): Promise<void> {
|
||||
if (this.loadingPromise && !force) {
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
this.loadingPromise = firstValueFrom(this.projectsCallService.retrieveAllProjects())
|
||||
.then((projects) => {
|
||||
this._projects.set(projects);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.loadingPromise = null;
|
||||
console.error('Retrieve projects failed', err);
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingPromise = null;
|
||||
});
|
||||
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
createProject(draft: ProjectDraft) {
|
||||
return this.projectsCallService.createProject(draft).pipe(
|
||||
tap((created) => this._projects.update((projects) => [created, ...projects])),
|
||||
catchError((err) => {
|
||||
console.error('Create project failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateProject(projectId: string, draft: ProjectDraft) {
|
||||
return this.projectsCallService.updateProject(projectId, draft).pipe(
|
||||
tap((updated) => this.patch(updated)),
|
||||
catchError((err) => {
|
||||
console.error('Update project failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateSharedContext(projectId: string, context: ProjectContext) {
|
||||
return this.projectsCallService.updateSharedContext(projectId, context).pipe(
|
||||
tap((updated) => this.patch(updated)),
|
||||
catchError((err) => {
|
||||
console.error('Update project shared context failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateFlowOrder(projectId: string, flowIds: string[]) {
|
||||
return this.projectsCallService.updateFlowOrder(projectId, flowIds).pipe(
|
||||
tap((updated) => this.patch(updated)),
|
||||
catchError((err) => {
|
||||
console.error('Update project flow order failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the project and every flow in it. The caller must also refresh the flow cache, which
|
||||
* this cascade leaves stale.
|
||||
*/
|
||||
deleteProject(projectId: string, confirm = true) {
|
||||
return this.projectsCallService.deleteProject(projectId, confirm).pipe(
|
||||
tap(() => this._projects.update((projects) => projects.filter((p) => p.id !== projectId))),
|
||||
catchError((err) => {
|
||||
console.error('Delete project failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private patch(project: Project) {
|
||||
this._projects.update((projects) => {
|
||||
const index = projects.findIndex((current) => current.id === project.id);
|
||||
if (index < 0) return [project, ...projects];
|
||||
const next = [...projects];
|
||||
next[index] = project;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
BiasRerunRequest
|
||||
} from '@models/bias-impact';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class TaskExecutionsCallServiceBase {
|
||||
|
|
@ -15,6 +16,16 @@ export abstract class TaskExecutionsCallServiceBase {
|
|||
abstract retrieveStepIterations(executionId: string, stepId: string): Observable<TaskExecution[]>;
|
||||
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
|
||||
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
|
||||
/**
|
||||
* Runs a whole project: creates one execution per flow, all sharing a projectRunId. It does not
|
||||
* start them - the client still supplies inputs and credentials, exactly as for a single flow.
|
||||
*/
|
||||
abstract createProjectExecutions(projectId: string, skipNonExecutable: boolean): Observable<ProjectExecutionPlan>;
|
||||
/**
|
||||
* Starts, or resumes, a project run. The flows run one at a time in the project's order; each
|
||||
* step starts only when the previous one succeeded.
|
||||
*/
|
||||
abstract startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun>;
|
||||
abstract rerunTaskExecution(executionId: string): Observable<TaskExecution>;
|
||||
abstract runBiasImpactExperiment(
|
||||
executionId: string,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
BiasRerunRequest
|
||||
} from '@models/bias-impact';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
|
||||
import { map, Observable, of, throwError } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
|
|
@ -527,6 +528,75 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return of(this.withSimulationAvailability(execution));
|
||||
}
|
||||
|
||||
override createProjectExecutions(projectId: string, _skipNonExecutable: boolean): Observable<ProjectExecutionPlan> {
|
||||
// The dev fake seeds flows '1' and '2' into project 'p1'; one execution per flow, sharing a run id.
|
||||
const projectRunId = crypto.randomUUID();
|
||||
const flowIds = projectId === 'p1' ? ['1'] : projectId === 'p2' ? ['2'] : [];
|
||||
const now = Date.now();
|
||||
|
||||
const executionIds = flowIds.map((flowId) => {
|
||||
const execution: TaskExecution = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${projectId} - ${flowId}`,
|
||||
creationTime: now,
|
||||
flowId,
|
||||
sourceFlowId: flowId,
|
||||
projectId,
|
||||
projectRunId,
|
||||
runNumber: this.nextRunNumber(flowId),
|
||||
simulationAvailable: false,
|
||||
context: {
|
||||
inputs: {},
|
||||
result: {},
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
errors: {},
|
||||
warnings: {},
|
||||
steps: {},
|
||||
status: 'CREATED',
|
||||
waitingSteps: []
|
||||
}
|
||||
};
|
||||
this.data.unshift(execution);
|
||||
return execution.id;
|
||||
});
|
||||
|
||||
return of({
|
||||
run: {
|
||||
projectRunId,
|
||||
projectId,
|
||||
name: projectId,
|
||||
createdAt: now,
|
||||
executionCount: executionIds.length,
|
||||
status: 'PENDING' as const,
|
||||
currentExecutionId: executionIds[0] ?? null,
|
||||
completedCount: 0,
|
||||
blockedReason: null,
|
||||
executionIds
|
||||
},
|
||||
skipped: []
|
||||
});
|
||||
}
|
||||
|
||||
override startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun> {
|
||||
const executionIds = this.data
|
||||
.filter((execution) => execution.projectRunId === projectRunId)
|
||||
.map((execution) => execution.id);
|
||||
|
||||
return of({
|
||||
projectRunId,
|
||||
projectId,
|
||||
name: projectId,
|
||||
createdAt: Date.now(),
|
||||
executionCount: executionIds.length,
|
||||
status: 'RUNNING' as const,
|
||||
currentExecutionId: executionIds[0] ?? null,
|
||||
completedCount: 0,
|
||||
blockedReason: null,
|
||||
executionIds
|
||||
});
|
||||
}
|
||||
|
||||
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
const source = this.findExecution(executionId);
|
||||
const sourceFlowId = this.executionSourceFlowId(source);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
BiasRoutingChangeEntry
|
||||
} from '@models/bias-impact';
|
||||
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
|
||||
import { ProjectExecutionPlan, ProjectRun } from '@models/project';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
|
|
@ -55,6 +56,58 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
);
|
||||
}
|
||||
|
||||
override createProjectExecutions(projectId: string, skipNonExecutable: boolean): Observable<ProjectExecutionPlan> {
|
||||
return this.http
|
||||
.post<unknown>(
|
||||
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}/execute?skipNonExecutable=${skipNonExecutable}`,
|
||||
{}
|
||||
)
|
||||
.pipe(map((raw) => this.mapProjectExecutionPlan(raw)));
|
||||
}
|
||||
|
||||
override startProjectRun(projectId: string, projectRunId: string): Observable<ProjectRun> {
|
||||
return this.http
|
||||
.post<unknown>(
|
||||
`${environment.apiUrl}/projects/${encodeURIComponent(projectId)}`
|
||||
+ `/runs/${encodeURIComponent(projectRunId)}/start`,
|
||||
{}
|
||||
)
|
||||
.pipe(map((raw) => this.mapProjectRun(raw)));
|
||||
}
|
||||
|
||||
private mapProjectRun(raw: unknown): ProjectRun {
|
||||
const run = (raw ?? {}) as Record<string, any>;
|
||||
const executions = Array.isArray(run['executions']) ? run['executions'] : [];
|
||||
|
||||
return {
|
||||
projectRunId: String(run['projectRunId'] ?? ''),
|
||||
projectId: String(run['projectId'] ?? ''),
|
||||
name: String(run['name'] ?? ''),
|
||||
createdAt: typeof run['createdAt'] === 'number' ? run['createdAt'] : Date.now(),
|
||||
executionCount: typeof run['executionCount'] === 'number' ? run['executionCount'] : executions.length,
|
||||
status: (run['status'] ?? 'PENDING') as ProjectRun['status'],
|
||||
currentExecutionId: typeof run['currentExecutionId'] === 'string' ? run['currentExecutionId'] : null,
|
||||
completedCount: typeof run['completedCount'] === 'number' ? run['completedCount'] : 0,
|
||||
blockedReason: typeof run['blockedReason'] === 'string' ? run['blockedReason'] : null,
|
||||
executionIds: executions
|
||||
.map((execution: Record<string, unknown>) => String(execution?.['id'] ?? ''))
|
||||
.filter((id: string) => id.length > 0)
|
||||
};
|
||||
}
|
||||
|
||||
private mapProjectExecutionPlan(raw: unknown): ProjectExecutionPlan {
|
||||
const value = (raw ?? {}) as Record<string, any>;
|
||||
|
||||
return {
|
||||
run: this.mapProjectRun(value['run']),
|
||||
skipped: (Array.isArray(value['skipped']) ? value['skipped'] : []).map((entry: Record<string, unknown>) => ({
|
||||
flowId: String(entry?.['flowId'] ?? ''),
|
||||
flowName: String(entry?.['flowName'] ?? ''),
|
||||
reason: String(entry?.['reason'] ?? 'Flow is not executable')
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
override rerunTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.post<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/rerun`, null).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
|
|
|
|||
|
|
@ -126,6 +126,33 @@ export class TaskExecutionsService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a whole project. Mirrors createExecution so the pending flag and the /tasks refresh come
|
||||
* for free.
|
||||
*/
|
||||
createProjectExecutions(projectId: string, skipNonExecutable = false) {
|
||||
this._pendingExecutionCreation.set(true);
|
||||
return this.taskExecutionsCallService.createProjectExecutions(projectId, skipNonExecutable).pipe(
|
||||
finalize(() => this._pendingExecutionCreation.set(false)),
|
||||
tap(() => this.refresh()),
|
||||
catchError((err) => {
|
||||
console.error('Create project executions failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts, or resumes, a project run. */
|
||||
startProjectRun(projectId: string, projectRunId: string) {
|
||||
return this.taskExecutionsCallService.startProjectRun(projectId, projectRunId).pipe(
|
||||
tap(() => this.refresh()),
|
||||
catchError((err) => {
|
||||
console.error('Start project run failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
rerunExecution(executionId: string) {
|
||||
this._pendingExecutionCreation.set(true);
|
||||
return this.taskExecutionsCallService.rerunTaskExecution(executionId).pipe(
|
||||
|
|
|
|||
|
|
@ -3,3 +3,10 @@
|
|||
* See docs/project-memory.md before changing this flag.
|
||||
*/
|
||||
export const SWIMLANES_ENABLED = false;
|
||||
|
||||
/**
|
||||
* Projects group the flow list. While this is false the flows list must render exactly as it did
|
||||
* before projects existed, which keeps "do not regress the flat list" provable rather than
|
||||
* aspirational. See docs/project-memory.md before changing this flag.
|
||||
*/
|
||||
export const PROJECTS_ENABLED = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { Flow } from '@models/flow';
|
||||
import { Project, UNGROUPED_PROJECT_KEY } from '@models/project';
|
||||
import { groupFlowsByProject } from './flow-grouping';
|
||||
|
||||
function makeFlow(id: string, projectId?: string): Flow {
|
||||
return {
|
||||
id,
|
||||
name: `Flow ${id}`,
|
||||
visibility: 'PRIVATE',
|
||||
data: { blocks: [], containers: [], connections: [], dependencies: [] },
|
||||
author: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
status: 'DRAFT',
|
||||
updatedAt: new Date('2026-01-02'),
|
||||
projectId
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(id: string, name: string): Project {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
owner: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
sharedContext: { entries: [] }
|
||||
};
|
||||
}
|
||||
|
||||
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('orders projects by name and always puts the ungrouped bucket last', () => {
|
||||
const groups = groupFlowsByProject(
|
||||
[makeFlow('a', 'p1'), makeFlow('b'), makeFlow('c', 'p2')],
|
||||
[zeta, alpha]
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.key)).toEqual(['p2', 'p1', UNGROUPED_PROJECT_KEY]);
|
||||
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]);
|
||||
|
||||
expect(groups.map((group) => group.key)).toEqual(['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')],
|
||||
[zeta]
|
||||
);
|
||||
|
||||
expect(groups[0].flows.map((flow) => flow.id)).toEqual(['third', 'first', 'second']);
|
||||
});
|
||||
|
||||
it('treats a flow whose project is unknown as ungrouped instead of hiding it', () => {
|
||||
// The backend withholds project membership from non-owners, and ids can go stale; a flow must
|
||||
// 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Flow } from '@models/flow';
|
||||
import { Project, UNGROUPED_PROJECT_KEY } from '@models/project';
|
||||
|
||||
export type FlowGroupView = {
|
||||
/** Project id, or UNGROUPED_PROJECT_KEY for the flows that belong to no project. */
|
||||
key: string;
|
||||
project: Project | null;
|
||||
flows: Flow[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups an already filtered and sorted flow list by project.
|
||||
*
|
||||
* 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.
|
||||
* - 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[] {
|
||||
const byId = new Map(projects.map((project) => [project.id, project]));
|
||||
|
||||
const groups = new Map<string, FlowGroupView>();
|
||||
for (const flow of flows) {
|
||||
const project = flow.projectId ? byId.get(flow.projectId) ?? null : null;
|
||||
const key = project ? project.id : UNGROUPED_PROJECT_KEY;
|
||||
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.flows.push(flow);
|
||||
} else {
|
||||
groups.set(key, { key, project, flows: [flow] });
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()].sort(compareGroups);
|
||||
}
|
||||
|
||||
function compareGroups(a: FlowGroupView, b: FlowGroupView): number {
|
||||
if (!a.project) return 1;
|
||||
if (!b.project) return -1;
|
||||
return a.project.name.localeCompare(b.project.name);
|
||||
}
|
||||
|
|
@ -25,6 +25,43 @@
|
|||
(click)="$event.stopPropagation(); clone()">
|
||||
<mat-icon fontIcon="content_copy"></mat-icon>
|
||||
</button>
|
||||
@if (projectsEnabled) {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="assignTooltip()"
|
||||
[disabled]="!canAssignProject()"
|
||||
[matMenuTriggerFor]="projectMenu"
|
||||
(click)="$event.stopPropagation()">
|
||||
<mat-icon fontIcon="drive_file_move"></mat-icon>
|
||||
</button>
|
||||
<mat-menu #projectMenu="matMenu">
|
||||
@for (project of assignableProjects(); track project.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="project.id === flow().projectId"
|
||||
(click)="assignToProject(project)">
|
||||
@if (project.id === flow().projectId) {
|
||||
<mat-icon fontIcon="check"></mat-icon>
|
||||
}
|
||||
<span>{{ project.name }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (assignableProjects().length === 0) {
|
||||
<button mat-menu-item type="button" disabled>
|
||||
<span>No projects yet</span>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="!flow().projectId"
|
||||
(click)="assignToProject(null)">
|
||||
<mat-icon fontIcon="remove"></mat-icon>
|
||||
<span>No project</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="deleteTooltip()"
|
||||
|
|
|
|||
|
|
@ -3,17 +3,22 @@ import { ChangeDetectionStrategy, Component, computed, inject, input, model } fr
|
|||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Flow } from '@models/flow';
|
||||
import { Project } from '@models/project';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { NotificationService } from '@services/notifications/notification';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { PROJECTS_ENABLED } from '@shared/feature-flags';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-flow-item',
|
||||
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatTooltipModule],
|
||||
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule],
|
||||
templateUrl: './flow-item.html',
|
||||
styleUrl: './flow-item.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
@ -25,6 +30,11 @@ export class FlowItem {
|
|||
private authorization = inject(Authorization);
|
||||
|
||||
private flowsService = inject(FlowsService);
|
||||
private projectsService = inject(ProjectsService);
|
||||
private notifications = inject(NotificationService);
|
||||
|
||||
readonly projectsEnabled = PROJECTS_ENABLED;
|
||||
readonly assignableProjects = this.projectsService.projects;
|
||||
|
||||
flow = input.required<Flow>();
|
||||
|
||||
|
|
@ -38,6 +48,34 @@ export class FlowItem {
|
|||
const username = this.authorization.loggedInUser()?.username ?? null;
|
||||
return !!username && this.flow().author === username && !this.flow().finalized;
|
||||
});
|
||||
/**
|
||||
* Ownership only - deliberately not gated on `finalized`. Assignment goes through the dedicated
|
||||
* project endpoint, not the flow update path, so a finalized flow stays reorganizable.
|
||||
*/
|
||||
canAssignProject = computed(() => {
|
||||
const username = this.authorization.loggedInUser()?.username ?? null;
|
||||
return !!username && this.flow().author === username;
|
||||
});
|
||||
assignTooltip = computed(() =>
|
||||
this.canAssignProject() ? 'Move to project' : 'Only the owner can move this flow'
|
||||
);
|
||||
|
||||
async assignToProject(project: Project | null) {
|
||||
if (!this.canAssignProject()) return;
|
||||
const targetId = project?.id ?? null;
|
||||
if ((this.flow().projectId ?? null) === targetId) return;
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.flowsService.assignFlowToProject(this.flow().id, targetId));
|
||||
this.notifications.show(
|
||||
project ? `Moved to “${project.name}”` : 'Removed from project',
|
||||
'success'
|
||||
);
|
||||
} catch {
|
||||
this.notifications.show('Could not move the flow.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
deleteTooltip = computed(() =>
|
||||
this.flow().finalized
|
||||
? 'Finalized flows cannot be deleted'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
.flows-list-group {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flows-list-group-headrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.flows-list-group-header {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.25rem 0.5rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.flows-list-group-chevron {
|
||||
flex: 0 0 auto;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.flows-list-group-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flows-list-group-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #0f172a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flows-list-group-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flows-list-group-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flows-list-group-flows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.flows-list-group-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flows-list-group-flow-item {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Compact arrows: the sidebar is 320px, so they must not crowd out the flow card. */
|
||||
.flows-list-group-move {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flows-list-group-move button {
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.flows-list-group-move mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<mat-card class="flows-list-group" [class.flows-list-group-expanded]="expanded()">
|
||||
<div class="flows-list-group-headrow">
|
||||
<button
|
||||
type="button"
|
||||
class="flows-list-group-header"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
(click)="onHeaderClick($event)">
|
||||
<mat-icon
|
||||
class="flows-list-group-chevron"
|
||||
[fontIcon]="expanded() ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<div class="flows-list-group-main">
|
||||
<div class="flows-list-group-title">{{ title() }}</div>
|
||||
@if (project()?.description) {
|
||||
<div class="flows-list-group-subtitle">{{ project()?.description }}</div>
|
||||
}
|
||||
</div>
|
||||
<span class="flows-list-group-count">{{ countLabel() }}</span>
|
||||
</button>
|
||||
|
||||
@if (!isUngrouped()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="flows-list-group-run"
|
||||
[matTooltip]="runTooltip()"
|
||||
[disabled]="!canRun()"
|
||||
[attr.aria-label]="'Run project ' + title()"
|
||||
(click)="$event.stopPropagation(); runRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="play_arrow"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="flows-list-group-actions"
|
||||
matTooltip="Project actions"
|
||||
[attr.aria-label]="'Actions for project ' + title()"
|
||||
[matMenuTriggerFor]="projectMenu"
|
||||
(click)="$event.stopPropagation()">
|
||||
<mat-icon fontIcon="more_vert"></mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #projectMenu="matMenu">
|
||||
<button mat-menu-item type="button" (click)="newFlowRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="note_add"></mat-icon>
|
||||
<span>New flow in this project</span>
|
||||
</button>
|
||||
<button mat-menu-item type="button" [disabled]="!canRun()" (click)="runRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="play_arrow"></mat-icon>
|
||||
<span>Run project</span>
|
||||
</button>
|
||||
<button mat-menu-item type="button" (click)="editRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="edit"></mat-icon>
|
||||
<span>Edit project</span>
|
||||
</button>
|
||||
<button mat-menu-item type="button" (click)="contextRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="data_object"></mat-icon>
|
||||
<span>Shared context…</span>
|
||||
</button>
|
||||
<button mat-menu-item type="button" (click)="deleteRequested.emit(project()!)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
<span>Delete project</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="flows-list-group-flows">
|
||||
@for (flow of flows(); track flow.id) {
|
||||
<div class="flows-list-group-flow">
|
||||
@if (canReorder()) {
|
||||
<div class="flows-list-group-move">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Run earlier in this project"
|
||||
[disabled]="$first"
|
||||
(click)="move(flow.id, -1, $event)">
|
||||
<mat-icon fontIcon="keyboard_arrow_up"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Run later in this project"
|
||||
[disabled]="$last"
|
||||
(click)="move(flow.id, 1, $event)">
|
||||
<mat-icon fontIcon="keyboard_arrow_down"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<app-flow-item class="flows-list-group-flow-item" [(detailOpenedId)]="detailOpenedId" [flow]="flow"></app-flow-item>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { Flow } from '@models/flow';
|
||||
import { Project } from '@models/project';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { signal } from '@angular/core';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { FlowsGroup } from './flows-group';
|
||||
|
||||
function makeFlow(id: string): Flow {
|
||||
return {
|
||||
id,
|
||||
name: `Flow ${id}`,
|
||||
visibility: 'PRIVATE',
|
||||
data: { blocks: [], containers: [], connections: [], dependencies: [] },
|
||||
author: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
status: 'DRAFT',
|
||||
updatedAt: new Date('2026-01-02')
|
||||
};
|
||||
}
|
||||
|
||||
const project: Project = {
|
||||
id: 'p1',
|
||||
name: 'Recruiting',
|
||||
description: 'Screening flows',
|
||||
owner: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
sharedContext: { entries: [] }
|
||||
};
|
||||
|
||||
async function build(inputs: { project: Project | null; flows: Flow[]; expanded: boolean }) {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FlowsGroup],
|
||||
providers: [
|
||||
{ provide: FlowsService, useValue: { flows: signal([]), assignFlowToProject: vi.fn() } },
|
||||
{ provide: ProjectsService, useValue: { projects: signal([]) } },
|
||||
{ provide: Authorization, useValue: { loggedInUser: signal({ username: 'alice' }) } }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(FlowsGroup);
|
||||
fixture.componentRef.setInput('project', inputs.project);
|
||||
fixture.componentRef.setInput('flows', inputs.flows);
|
||||
fixture.componentRef.setInput('expanded', inputs.expanded);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
describe('FlowsGroup', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('shows the project name and a pluralised flow count', async () => {
|
||||
const fixture = await build({ project, flows: [makeFlow('1'), makeFlow('2')], expanded: false });
|
||||
|
||||
expect(fixture.componentInstance.title()).toBe('Recruiting');
|
||||
expect(fixture.componentInstance.countLabel()).toBe('2 flows');
|
||||
});
|
||||
|
||||
it('uses the singular for one flow', async () => {
|
||||
const fixture = await build({ project, flows: [makeFlow('1')], expanded: false });
|
||||
|
||||
expect(fixture.componentInstance.countLabel()).toBe('1 flow');
|
||||
});
|
||||
|
||||
it('renders the flows only while expanded', async () => {
|
||||
const collapsed = await build({ project, flows: [makeFlow('1')], expanded: false });
|
||||
expect(collapsed.nativeElement.querySelectorAll('app-flow-item').length).toBe(0);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
const expanded = await build({ project, flows: [makeFlow('1')], expanded: true });
|
||||
expect(expanded.nativeElement.querySelectorAll('app-flow-item').length).toBe(1);
|
||||
});
|
||||
|
||||
it('labels the ungrouped bucket and gives it no project actions', async () => {
|
||||
const fixture = await build({ project: null, flows: [makeFlow('1')], expanded: true });
|
||||
|
||||
expect(fixture.componentInstance.title()).toBe('No project');
|
||||
expect(fixture.componentInstance.isUngrouped()).toBe(true);
|
||||
// The pseudo-group is not a project, so edit/delete/context would be meaningless.
|
||||
expect(fixture.nativeElement.querySelector('.flows-list-group-actions')).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a header click as a toggle intent', async () => {
|
||||
const fixture = await build({ project, flows: [makeFlow('1')], expanded: false });
|
||||
const toggled = vi.fn();
|
||||
fixture.componentInstance.toggled.subscribe(toggled);
|
||||
|
||||
fixture.nativeElement.querySelector('.flows-list-group-header').click();
|
||||
|
||||
expect(toggled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Flow } from '@models/flow';
|
||||
import { Project } from '@models/project';
|
||||
import { FlowItem } from '../flow-item/flow-item';
|
||||
|
||||
/**
|
||||
* One collapsible project section of the flows list. Purely presentational: the list owns the
|
||||
* grouping and the expansion state, this only renders and reports intent.
|
||||
*
|
||||
* The DOM shape and class naming deliberately mirror `tasks-executions-list`, which already
|
||||
* implements collapsible groups, so the two sidebars read as one product.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-flows-group',
|
||||
imports: [FlowItem, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule],
|
||||
templateUrl: './flows-group.html',
|
||||
styleUrl: './flows-group.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FlowsGroup {
|
||||
readonly project = input<Project | null>(null);
|
||||
readonly flows = input.required<Flow[]>();
|
||||
readonly expanded = input<boolean>(false);
|
||||
readonly detailOpenedId = model<string | null>(null);
|
||||
|
||||
readonly toggled = output<void>();
|
||||
readonly editRequested = output<Project>();
|
||||
readonly contextRequested = output<Project>();
|
||||
readonly deleteRequested = output<Project>();
|
||||
readonly newFlowRequested = output<Project>();
|
||||
readonly runRequested = output<Project>();
|
||||
readonly moveRequested = output<{ project: Project; flowId: string; direction: -1 | 1 }>();
|
||||
|
||||
/** The ungrouped bucket is a pseudo-project: it has no actions of its own. */
|
||||
readonly title = computed(() => this.project()?.name ?? 'No project');
|
||||
readonly isUngrouped = computed(() => this.project() === null);
|
||||
/** A project run needs at least one executable flow, and the backend refuses the rest. */
|
||||
readonly canRun = computed(() => this.flows().some((flow) => flow.status === 'EXECUTABLE'));
|
||||
|
||||
readonly runTooltip = computed(() =>
|
||||
this.canRun() ? 'Run every executable flow in this project' : 'No executable flow in this project'
|
||||
);
|
||||
|
||||
readonly countLabel = computed(() => {
|
||||
const count = this.flows().length;
|
||||
return `${count} ${count === 1 ? 'flow' : 'flows'}`;
|
||||
});
|
||||
|
||||
/** Reordering is per-project, so it is offered only inside a real project group. */
|
||||
readonly canReorder = computed(() => !this.isUngrouped() && this.flows().length > 1);
|
||||
|
||||
move(flowId: string, direction: -1 | 1, event: Event) {
|
||||
event.stopPropagation();
|
||||
const project = this.project();
|
||||
if (project) {
|
||||
this.moveRequested.emit({ project, flowId, direction });
|
||||
}
|
||||
}
|
||||
|
||||
onHeaderClick(event: Event) {
|
||||
event.stopPropagation();
|
||||
this.toggled.emit();
|
||||
}
|
||||
}
|
||||
|
|
@ -75,3 +75,27 @@
|
|||
overflow: visible !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The grouped container relies on the flex column for its height instead of the flat list's fixed
|
||||
* max-height calc, which does not account for the project filter row above it.
|
||||
*/
|
||||
.flows-list-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: none;
|
||||
padding: 10px 0 20px;
|
||||
}
|
||||
|
||||
.flows-list-project-filter {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.flows-list-project-filter mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flows-list-project-filter .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,44 @@
|
|||
<div class="flows-list-ordering">
|
||||
<app-ordering [orderFields]="orderFields" [orderView]="orderView" (orderChanged)="onOrderChanged($event)" ></app-ordering>
|
||||
</div>
|
||||
|
||||
@if (projectsEnabled && projects().length > 0) {
|
||||
<div class="flows-list-project-filter">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Project</mat-label>
|
||||
<mat-select [value]="projectFilter()" (valueChange)="onProjectFilterChange($event)">
|
||||
<mat-option value="all">All</mat-option>
|
||||
@for (project of projects(); track project.id) {
|
||||
<mat-option [value]="project.id">{{ project.name }}</mat-option>
|
||||
}
|
||||
<mat-option [value]="ungroupedKey">No project</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (orderedFlows().length === 0) {
|
||||
<div class="flows-list-empty">
|
||||
No flows found
|
||||
</div>
|
||||
} @else if (groupingActive()) {
|
||||
<div class="flows-list-items flows-list-groups">
|
||||
@for (group of groupedFlows(); track group.key) {
|
||||
<app-flows-group
|
||||
[project]="group.project"
|
||||
[flows]="group.flows"
|
||||
[expanded]="isGroupExpanded(group.key)"
|
||||
[(detailOpenedId)]="detailOpenedId"
|
||||
(toggled)="toggleGroup(group.key)"
|
||||
(editRequested)="editProject($event)"
|
||||
(contextRequested)="editProjectContext($event)"
|
||||
(deleteRequested)="deleteProject($event)"
|
||||
(newFlowRequested)="createFlowInProject($event)"
|
||||
(runRequested)="runProject($event)"
|
||||
(moveRequested)="moveFlowInProject($event)">
|
||||
</app-flows-group>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<mat-list class="flows-list-items">
|
||||
@for (flow of orderedFlows(); track flow.id) {
|
||||
|
|
|
|||
|
|
@ -1,39 +1,235 @@
|
|||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Flow } from '@models/flow';
|
||||
import { Project } from '@models/project';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { ListState } from '@stores/list-state';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { FlowsList } from './flows-list';
|
||||
|
||||
describe('FlowsList', () => {
|
||||
let component: FlowsList;
|
||||
let fixture: ComponentFixture<FlowsList>;
|
||||
function makeFlow(id: string, name: string, projectId?: string): Flow {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
visibility: 'PRIVATE',
|
||||
data: { blocks: [], containers: [], connections: [], dependencies: [] },
|
||||
author: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
status: 'DRAFT',
|
||||
updatedAt: new Date('2026-01-02'),
|
||||
projectId
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FlowsList],
|
||||
providers: [
|
||||
ListState,
|
||||
{
|
||||
provide: FlowsService,
|
||||
useValue: {
|
||||
flows: signal([]),
|
||||
hasLoadedFlows: vi.fn().mockReturnValue(true),
|
||||
getAllFlows: vi.fn().mockResolvedValue(signal([]))
|
||||
}
|
||||
function makeProject(id: string, name: string): Project {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
owner: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
sharedContext: { entries: [] }
|
||||
};
|
||||
}
|
||||
|
||||
let taskExecutionsStub: {
|
||||
createProjectExecutions: ReturnType<typeof vi.fn>;
|
||||
startProjectRun: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
async function build(flows: Flow[], projects: Project[]) {
|
||||
taskExecutionsStub = {
|
||||
createProjectExecutions: vi.fn(),
|
||||
startProjectRun: vi.fn().mockReturnValue(of({
|
||||
projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1,
|
||||
executionCount: 1, status: 'RUNNING', currentExecutionId: 'e1',
|
||||
completedCount: 0, blockedReason: null, executionIds: ['e1']
|
||||
}))
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FlowsList],
|
||||
providers: [
|
||||
ListState,
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: FlowsService,
|
||||
useValue: {
|
||||
flows: signal(flows),
|
||||
hasLoadedFlows: vi.fn().mockReturnValue(true),
|
||||
getAllFlows: vi.fn().mockResolvedValue(signal(flows)),
|
||||
refresh: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
]
|
||||
})
|
||||
.compileComponents();
|
||||
},
|
||||
{
|
||||
provide: ProjectsService,
|
||||
useValue: {
|
||||
projects: signal(projects),
|
||||
hasLoadedProjects: vi.fn().mockReturnValue(true),
|
||||
getAllProjects: vi.fn().mockResolvedValue(signal(projects))
|
||||
}
|
||||
},
|
||||
{ provide: TaskExecutionsService, useValue: taskExecutionsStub }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FlowsList);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const fixture = TestBed.createComponent(FlowsList);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
describe('FlowsList', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('should create', async () => {
|
||||
const fixture = await build([], []);
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
it('renders the flat list, with no group headers, when there are no projects', async () => {
|
||||
// Regression guard: a user with no projects must see exactly the pre-projects list.
|
||||
const fixture = await build([makeFlow('1', 'Alpha'), makeFlow('2', 'Beta')], []);
|
||||
|
||||
expect(fixture.componentInstance.groupingActive()).toBe(false);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-flows-group').length).toBe(0);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-flow-item').length).toBe(2);
|
||||
});
|
||||
|
||||
it('groups the flows once projects exist', async () => {
|
||||
const fixture: ComponentFixture<FlowsList> = await build(
|
||||
[makeFlow('1', 'Alpha', 'p1'), makeFlow('2', 'Beta')],
|
||||
[makeProject('p1', 'Recruiting')]
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.groupingActive()).toBe(true);
|
||||
const groups = fixture.componentInstance.groupedFlows();
|
||||
expect(groups.map((group) => group.key)).toEqual(['p1', '__ungrouped__']);
|
||||
expect(fixture.nativeElement.querySelectorAll('app-flows-group').length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders a flat list when a single project is selected', async () => {
|
||||
const fixture = await build(
|
||||
[makeFlow('1', 'Alpha', 'p1'), makeFlow('2', 'Beta')],
|
||||
[makeProject('p1', 'Recruiting')]
|
||||
);
|
||||
|
||||
fixture.componentInstance.onProjectFilterChange('p1');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.groupingActive()).toBe(false);
|
||||
expect(fixture.componentInstance.orderedFlows().map((flow) => flow.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('scopes the list to unassigned flows when "No project" is selected', async () => {
|
||||
const fixture = await build(
|
||||
[makeFlow('1', 'Alpha', 'p1'), makeFlow('2', 'Beta')],
|
||||
[makeProject('p1', 'Recruiting')]
|
||||
);
|
||||
|
||||
fixture.componentInstance.onProjectFilterChange('__ungrouped__');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.orderedFlows().map((flow) => flow.id)).toEqual(['2']);
|
||||
});
|
||||
|
||||
it('auto-expands groups while a search term is active, without touching the saved expansion', async () => {
|
||||
const fixture = await build(
|
||||
[makeFlow('1', 'Alpha', 'p1')],
|
||||
[makeProject('p1', 'Recruiting')]
|
||||
);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.isGroupExpanded('p1')).toBe(false);
|
||||
|
||||
component.searchTerm.set('alp');
|
||||
fixture.detectChanges();
|
||||
// A search hit must never sit hidden behind a collapsed header.
|
||||
expect(component.isGroupExpanded('p1')).toBe(true);
|
||||
|
||||
component.searchTerm.set('');
|
||||
fixture.detectChanges();
|
||||
// Clearing the search restores the user's own expansion rather than leaving it forced open.
|
||||
expect(component.isGroupExpanded('p1')).toBe(false);
|
||||
});
|
||||
|
||||
it('persists group expansion in the shared list state', async () => {
|
||||
const fixture = await build(
|
||||
[makeFlow('1', 'Alpha', 'p1')],
|
||||
[makeProject('p1', 'Recruiting')]
|
||||
);
|
||||
|
||||
fixture.componentInstance.toggleGroup('p1');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.isGroupExpanded('p1')).toBe(true);
|
||||
expect(fixture.componentInstance.view.expandedGroupIds?.has('p1')).toBe(true);
|
||||
});
|
||||
|
||||
it('runs a project and navigates to the created executions', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
|
||||
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1, executionIds: ['e1'] },
|
||||
skipped: []
|
||||
}));
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
// Non-executable flows are skipped rather than failing the whole run from the UI.
|
||||
expect(taskExecutionsStub.createProjectExecutions).toHaveBeenCalledWith('p1', true);
|
||||
// Creating is not enough: the run must also be started, or nothing would happen.
|
||||
expect(taskExecutionsStub.startProjectRun).toHaveBeenCalledWith('p1', 'r1');
|
||||
expect(navigate).toHaveBeenCalledWith(['/tasks'], { queryParams: { executionId: 'e1' } });
|
||||
});
|
||||
|
||||
it('still navigates when a project run produced no executions', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
|
||||
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 0, executionIds: [] },
|
||||
skipped: [{ flowId: '1', flowName: 'Alpha', reason: 'Flow is not executable' }]
|
||||
}));
|
||||
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith(['/tasks'], { queryParams: {} });
|
||||
});
|
||||
|
||||
it('does not navigate when the project run fails', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(throwError(() => new Error('boom')));
|
||||
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a blocked run instead of claiming it is running', async () => {
|
||||
const fixture = await build([makeFlow('1', 'Alpha', 'p1')], [makeProject('p1', 'Recruiting')]);
|
||||
taskExecutionsStub.createProjectExecutions.mockReturnValue(of({
|
||||
run: { projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1,
|
||||
status: 'PENDING', currentExecutionId: 'e1', completedCount: 0, blockedReason: null, executionIds: ['e1'] },
|
||||
skipped: []
|
||||
}));
|
||||
taskExecutionsStub.startProjectRun.mockReturnValue(of({
|
||||
projectRunId: 'r1', projectId: 'p1', name: 'Recruiting', createdAt: 1, executionCount: 1,
|
||||
status: 'BLOCKED', currentExecutionId: 'e1', completedCount: 0,
|
||||
blockedReason: 'missing global inputs: requirements', executionIds: ['e1']
|
||||
}));
|
||||
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
|
||||
await fixture.componentInstance.runProject(makeProject('p1', 'Recruiting'));
|
||||
|
||||
// Still navigates, so the user can supply what the run is waiting for.
|
||||
expect(navigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Flow, FlowVisibility } from '@models/flow';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { FlowItem } from './flow-item/flow-item';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
|
|
@ -9,14 +11,29 @@ import { MatIconModule } from '@angular/material/icon';
|
|||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { Project, UNGROUPED_PROJECT_KEY } from '@models/project';
|
||||
import { ProjectContextDialogService } from '@services/dialogs/project-context-dialog';
|
||||
import { ProjectDeleteDialogService } from '@services/dialogs/project-delete-dialog';
|
||||
import { ProjectDialogService } from '@services/dialogs/project-dialog';
|
||||
import { NotificationService } from '@services/notifications/notification';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { PROJECTS_ENABLED } from '@shared/feature-flags';
|
||||
import { OrderEvent, OrderField, Ordering, orderDirType } from "@shared/ordering/ordering";
|
||||
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
|
||||
import { FlowsGroup } from './flows-group/flows-group';
|
||||
import { groupFlowsByProject } from './flow-grouping';
|
||||
|
||||
type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
|
||||
|
||||
/** 'all' groups by project; a concrete id (or the ungrouped key) renders a flat, scoped list. */
|
||||
const ALL_PROJECTS = 'all';
|
||||
|
||||
@Component({
|
||||
selector: 'app-flows-list',
|
||||
imports: [FlowItem, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule],
|
||||
imports: [FlowItem, FlowsGroup, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule, MatSelectModule],
|
||||
templateUrl: './flows-list.html',
|
||||
styleUrl: './flows-list.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
@ -36,6 +53,22 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
searchTerm = model<string>('');
|
||||
|
||||
private flowsService = inject(FlowsService);
|
||||
private projectsService = inject(ProjectsService);
|
||||
private projectDialog = inject(ProjectDialogService);
|
||||
private projectDeleteDialog = inject(ProjectDeleteDialogService);
|
||||
private projectContextDialog = inject(ProjectContextDialogService);
|
||||
private notifications = inject(NotificationService);
|
||||
private taskExecutions = inject(TaskExecutionsService);
|
||||
private router = inject(Router);
|
||||
private editorState = inject(EditorStateHolder);
|
||||
|
||||
readonly projectsEnabled = PROJECTS_ENABLED;
|
||||
readonly ungroupedKey = UNGROUPED_PROJECT_KEY;
|
||||
|
||||
readonly projects = this.projectsService.projects;
|
||||
|
||||
/** 'all' | UNGROUPED_PROJECT_KEY | <projectId> */
|
||||
readonly projectFilter = signal<string>(ALL_PROJECTS);
|
||||
|
||||
loading: WritableSignal<boolean> = signal(true);
|
||||
|
||||
|
|
@ -48,6 +81,8 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
|
||||
detailOpenedId = signal<string | null>(null);
|
||||
|
||||
private readonly expandedGroupIds = signal<Set<string>>(new Set<string>());
|
||||
|
||||
constructor() {
|
||||
super('flowsList', {defaultOrder: { orderBy: 'name', orderDir: 'asc' } as OrderViewState, defaultFilter: 'all'});
|
||||
effect(() => {
|
||||
|
|
@ -57,6 +92,16 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Loaded alongside the flows, never gated on them: if the projects endpoint fails the list
|
||||
// simply falls back to the flat rendering. A project must never be able to break the flows.
|
||||
this.projectFilter.set((this.view.secondaryFilter as string) ?? ALL_PROJECTS);
|
||||
this.expandedGroupIds.set(this.view.expandedGroupIds ?? new Set<string>());
|
||||
if (PROJECTS_ENABLED && !this.projectsService.hasLoadedProjects()) {
|
||||
this.projectsService.getAllProjects().catch((err) => {
|
||||
console.error('Error loading projects', err);
|
||||
});
|
||||
}
|
||||
|
||||
const existingState = this.view;
|
||||
this.orderBy.set(existingState.order.orderBy);
|
||||
this.orderDir.set(existingState.order.orderDir);
|
||||
|
|
@ -89,12 +134,20 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
filteredFlows = computed(() => {
|
||||
const flows = this.flows ? this.flows() : [];
|
||||
if (!flows) return [];
|
||||
const filteredFlows = flows.filter(f => f.name.toLowerCase().includes(this.searchTerm().toLowerCase()));
|
||||
if (this.filter() === 'all') return filteredFlows;
|
||||
if (this.filter() === 'FINALIZED') return filteredFlows.filter(f => !!f.finalized);
|
||||
return filteredFlows.filter(f => f.visibility === this.filter());
|
||||
const searched = flows.filter(f => f.name.toLowerCase().includes(this.searchTerm().toLowerCase()));
|
||||
const scoped = this.scopeToProject(searched);
|
||||
if (this.filter() === 'all') return scoped;
|
||||
if (this.filter() === 'FINALIZED') return scoped.filter(f => !!f.finalized);
|
||||
return scoped.filter(f => f.visibility === this.filter());
|
||||
});
|
||||
|
||||
private scopeToProject(flows: Flow[]): Flow[] {
|
||||
const selected = this.projectFilter();
|
||||
if (!PROJECTS_ENABLED || selected === ALL_PROJECTS) return flows;
|
||||
if (selected === UNGROUPED_PROJECT_KEY) return flows.filter(f => !f.projectId);
|
||||
return flows.filter(f => f.projectId === selected);
|
||||
}
|
||||
|
||||
orderedFlows = computed(() => {
|
||||
const flows = [...this.filteredFlows()];
|
||||
const orderBy = this.orderBy();
|
||||
|
|
@ -114,6 +167,172 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
});
|
||||
|
||||
|
||||
/**
|
||||
* Grouping is on only when there is something to group by and no single project is selected -
|
||||
* picking one project renders a flat, scoped list, which is clearer and cheaper than a single
|
||||
* group. With no projects at all the list renders exactly as it did before projects existed.
|
||||
*/
|
||||
readonly groupingActive = computed(() =>
|
||||
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 searchActive = computed(() => this.searchTerm().trim().length > 0);
|
||||
|
||||
/**
|
||||
* While a search term is active every surviving group renders expanded, derived rather than by
|
||||
* mutating the persisted set - so clearing the search restores the user's own expansion. A
|
||||
* search hit must never sit hidden behind a collapsed header.
|
||||
*/
|
||||
isGroupExpanded(key: string): boolean {
|
||||
return this.searchActive() || this.expandedGroupIds().has(key);
|
||||
}
|
||||
|
||||
toggleGroup(key: string) {
|
||||
this.expandedGroupIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
this.view.expandedGroupIds = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
onProjectFilterChange(value: string) {
|
||||
this.projectFilter.set(value);
|
||||
this.view.secondaryFilter = value;
|
||||
}
|
||||
|
||||
async createProject() {
|
||||
const draft = await this.projectDialog.open({ project: null });
|
||||
if (!draft) return;
|
||||
|
||||
try {
|
||||
const created = await firstValueFrom(this.projectsService.createProject(draft));
|
||||
this.toggleGroup(created.id);
|
||||
this.notifications.show(`Project “${created.name}” created`, 'success');
|
||||
} catch {
|
||||
this.notifications.show('Could not create the project.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async editProject(project: Project) {
|
||||
const draft = await this.projectDialog.open({ project });
|
||||
if (!draft) return;
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.projectsService.updateProject(project.id, draft));
|
||||
this.notifications.show('Project updated', 'success');
|
||||
} catch {
|
||||
this.notifications.show('Could not update the project.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async editProjectContext(project: Project) {
|
||||
const context = await this.projectContextDialog.open(project);
|
||||
if (!context) return;
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.projectsService.updateSharedContext(project.id, context));
|
||||
this.notifications.show('Shared context saved', 'success');
|
||||
} catch {
|
||||
this.notifications.show('Could not save the shared context.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a project destroys its flows too. The count shown comes from the client cache, so it
|
||||
* matches exactly what the user has in front of them.
|
||||
*/
|
||||
async deleteProject(project: Project) {
|
||||
const flows = (this.flows ? this.flows() : []).filter((flow) => flow.projectId === project.id);
|
||||
const confirmed = await this.projectDeleteDialog.open({
|
||||
project,
|
||||
flows: flows.map((flow) => ({ id: flow.id, name: flow.name, finalized: flow.finalized }))
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.projectsService.deleteProject(project.id, true));
|
||||
// The open document may have just been deleted along with the project.
|
||||
const openFlowId = this.editorState.currentFlow()?.id;
|
||||
if (openFlowId && flows.some((flow) => flow.id === openFlowId)) {
|
||||
this.editorState.closeDocument();
|
||||
}
|
||||
// The flow cache is stale after a cascade: N flows are gone server-side.
|
||||
await this.flowsService.refresh(true);
|
||||
this.notifications.show(`Project “${project.name}” deleted`, 'success');
|
||||
} catch {
|
||||
this.notifications.show('Could not delete the project.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one execution per executable flow in the project and shows them in /tasks. They are
|
||||
* created, not started: each still needs its inputs and credentials, exactly as a single run does.
|
||||
*/
|
||||
async runProject(project: Project) {
|
||||
try {
|
||||
const plan = await firstValueFrom(
|
||||
this.taskExecutions.createProjectExecutions(project.id, true));
|
||||
|
||||
// The flows run one at a time in the project's order; each step starts only when the
|
||||
// previous one succeeded, so this kicks off the first and the backend advances the rest.
|
||||
const run = await firstValueFrom(
|
||||
this.taskExecutions.startProjectRun(project.id, plan.run.projectRunId));
|
||||
|
||||
const skipped = plan.skipped.length;
|
||||
const skippedNote = skipped > 0 ? `; ${skipped} flow(s) skipped as not executable` : '';
|
||||
this.notifications.show(
|
||||
run.status === 'BLOCKED'
|
||||
? `“${project.name}” is waiting: ${run.blockedReason ?? 'the first flow needs inputs'}`
|
||||
: `Running “${project.name}” — ${plan.run.executionCount} flow(s), one at a time${skippedNote}`,
|
||||
run.status === 'BLOCKED' ? 'info' : 'success');
|
||||
|
||||
await this.router.navigate(['/tasks'], {
|
||||
queryParams: plan.run.executionIds.length ? { executionId: plan.run.executionIds[0] } : {}
|
||||
});
|
||||
} catch {
|
||||
this.notifications.show('Could not run the project.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves a flow one position within its project, persisting the run order. */
|
||||
async moveFlowInProject(event: { project: Project; flowId: string; direction: -1 | 1 }) {
|
||||
const current = this.groupedFlows().find((group) => group.key === event.project.id)?.flows ?? [];
|
||||
const index = current.findIndex((flow) => flow.id === event.flowId);
|
||||
const target = index + event.direction;
|
||||
if (index < 0 || target < 0 || target >= current.length) return;
|
||||
|
||||
const reordered = [...current];
|
||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.projectsService.updateFlowOrder(
|
||||
event.project.id, reordered.map((flow) => flow.id)));
|
||||
await this.flowsService.refresh(true);
|
||||
} catch {
|
||||
this.notifications.show('Could not reorder the flows.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async createFlowInProject(project: Project) {
|
||||
try {
|
||||
const created = await firstValueFrom(this.flowsService.createNewFlow());
|
||||
await firstValueFrom(this.flowsService.assignFlowToProject(created.id, project.id));
|
||||
this.toggleGroupOn(project.id);
|
||||
} catch {
|
||||
this.notifications.show('Could not create the flow.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
private toggleGroupOn(key: string) {
|
||||
if (this.expandedGroupIds().has(key)) return;
|
||||
this.toggleGroup(key);
|
||||
}
|
||||
|
||||
onOrderChanged(event: OrderEvent) {
|
||||
const { orderBy, orderDir } = event;
|
||||
this.orderBy.set(orderBy);
|
||||
|
|
|
|||
|
|
@ -670,6 +670,11 @@ export class TaskStepNodeComponent {
|
|||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
private projectContextValue(): Record<string, unknown> {
|
||||
const value = this.blockConfiguration?.['__projectContext'];
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
private executionName(): string | null {
|
||||
const value = this.blockConfiguration?.['__executionName'];
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
|
|
@ -680,7 +685,7 @@ export class TaskStepNodeComponent {
|
|||
return buildTemplateSubstitutions(inputs, this.globalInputsValue(), this.executionVariablesValue(), {
|
||||
executionId: this.executionId(),
|
||||
executionName: this.executionName()
|
||||
});
|
||||
}, this.projectContextValue());
|
||||
}
|
||||
|
||||
private currentInputValue(): string {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
.project-context__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.project-context__empty {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.project-context__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-context__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.project-context__name {
|
||||
flex: 2 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-context__type {
|
||||
flex: 1 1 0;
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
.project-context__value {
|
||||
flex: 3 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-context__reference {
|
||||
display: inline-block;
|
||||
margin: -0.5rem 0 0.5rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.project-context__add {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.project-context__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
@if (state(); as dialogState) {
|
||||
<app-modal-shell
|
||||
title="Shared context"
|
||||
[subtitle]="'Values every flow in “' + dialogState.project.name + '” can read at run time.'"
|
||||
ariaLabel="Project shared context"
|
||||
maxWidth="720px"
|
||||
closeLabel="Cancel"
|
||||
(backdropClick)="cancel()"
|
||||
(closeClick)="cancel()">
|
||||
|
||||
<div class="project-context__body">
|
||||
@if (entries().length === 0) {
|
||||
<p class="project-context__empty">No shared variables defined.</p>
|
||||
} @else {
|
||||
<div class="project-context__rows">
|
||||
@for (entry of entries(); track $index) {
|
||||
<div class="project-context__row">
|
||||
<mat-form-field appearance="outline" class="project-context__name">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="entry.name"
|
||||
(ngModelChange)="updateName($index, $event)"
|
||||
[name]="'entry-name-' + $index"
|
||||
autocomplete="off" />
|
||||
@if (validationErrors()[$index]; as error) {
|
||||
<mat-error>{{ error }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="project-context__type">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select [value]="entry.type" (valueChange)="updateType($index, $event)">
|
||||
@for (type of entryTypes; track type) {
|
||||
<mat-option [value]="type">{{ type }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="project-context__value">
|
||||
<mat-label>Value</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="entry.value"
|
||||
(ngModelChange)="updateValue($index, $event)"
|
||||
[name]="'entry-value-' + $index"
|
||||
autocomplete="off" />
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="project-context__copy"
|
||||
[disabled]="!entry.name"
|
||||
[matTooltip]="entry.name ? 'Copy ' + templateReference(entry.name) : 'Name the variable first'"
|
||||
(click)="copyReference(entry.name)">
|
||||
<mat-icon fontIcon="content_copy"></mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
matTooltip="Remove variable"
|
||||
(click)="removeEntry($index)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (entry.name && !validationErrors()[$index]) {
|
||||
<code class="project-context__reference">{{ templateReference(entry.name) }}</code>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" mat-stroked-button class="project-context__add" (click)="addEntry()">
|
||||
<mat-icon fontIcon="add"></mat-icon>
|
||||
Add variable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="project-context__footer">
|
||||
<button type="button" mat-stroked-button (click)="cancel()">Cancel</button>
|
||||
<button type="button" mat-flat-button color="primary" [disabled]="hasErrors()" (click)="save()">
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import {
|
||||
ProjectContext,
|
||||
ProjectContextEntry,
|
||||
ProjectContextEntryType,
|
||||
projectTemplateReference
|
||||
} from '@models/project';
|
||||
import { ProjectContextDialogService } from '@services/dialogs/project-context-dialog';
|
||||
import { NotificationService } from '@services/notifications/notification';
|
||||
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
|
||||
|
||||
/** Names starting with these would collide with the runtime's own placeholder namespaces. */
|
||||
const RESERVED_PREFIXES = ['project.', 'global.', 'context.', 'vars.'];
|
||||
const VALID_NAME = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
||||
|
||||
/**
|
||||
* Editor for the values a project shares with its flows.
|
||||
*
|
||||
* Modelled directly on the Global Inputs panel in the title toolbar: users already map that panel
|
||||
* onto `${{global.x}}`, and these values work the same way under `${{project.x}}`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-project-context-dialog',
|
||||
imports: [
|
||||
FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule,
|
||||
MatTooltipModule, ModalShellComponent
|
||||
],
|
||||
templateUrl: './project-context-dialog.html',
|
||||
styleUrl: './project-context-dialog.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProjectContextDialogComponent {
|
||||
private readonly dialog = inject(ProjectContextDialogService);
|
||||
private readonly notifications = inject(NotificationService);
|
||||
|
||||
readonly state = this.dialog.state;
|
||||
readonly entries = signal<ProjectContextEntry[]>([]);
|
||||
|
||||
readonly entryTypes: ProjectContextEntryType[] = ['TEXT', 'BOOLEAN', 'JSON', 'CSV'];
|
||||
|
||||
/** Same rules, wording and shape as TitleToolbar.globalInputValidationErrors. */
|
||||
readonly validationErrors = computed(() => {
|
||||
const entries = this.entries();
|
||||
const nameCounts = new Map<string, number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const normalized = String(entry.name ?? '').trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
nameCounts.set(normalized, (nameCounts.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return entries.map((entry) => {
|
||||
const name = String(entry.name ?? '').trim();
|
||||
if (!name) return 'Name is required';
|
||||
if ((nameCounts.get(name.toLowerCase()) ?? 0) > 1) return 'Name must be unique';
|
||||
if (RESERVED_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) {
|
||||
return 'Name must not start with project., global., context. or vars.';
|
||||
}
|
||||
if (!VALID_NAME.test(name)) return 'Name must be usable as a ${{...}} placeholder';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
readonly hasErrors = computed(() => this.validationErrors().some((message) => !!message));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const project = this.state()?.project;
|
||||
this.entries.set(project ? project.sharedContext.entries.map((entry) => ({ ...entry })) : []);
|
||||
});
|
||||
}
|
||||
|
||||
templateReference(name: string): string {
|
||||
return projectTemplateReference(name);
|
||||
}
|
||||
|
||||
addEntry() {
|
||||
this.entries.update((entries) => [
|
||||
...entries,
|
||||
{ name: '', type: 'TEXT', multiple: false, value: '', description: null }
|
||||
]);
|
||||
}
|
||||
|
||||
removeEntry(index: number) {
|
||||
this.entries.update((entries) => entries.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
updateName(index: number, name: string) {
|
||||
this.patch(index, { name });
|
||||
}
|
||||
|
||||
updateType(index: number, type: ProjectContextEntryType) {
|
||||
this.patch(index, { type });
|
||||
}
|
||||
|
||||
updateValue(index: number, value: string) {
|
||||
this.patch(index, { value });
|
||||
}
|
||||
|
||||
async copyReference(name: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.templateReference(name));
|
||||
this.notifications.show('Placeholder copied', 'success');
|
||||
} catch {
|
||||
// Clipboard access can be denied; the reference is visible on screen either way.
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dialog.close(null);
|
||||
}
|
||||
|
||||
save() {
|
||||
if (this.hasErrors()) return;
|
||||
const context: ProjectContext = {
|
||||
entries: this.entries().map((entry) => ({ ...entry, name: entry.name.trim() }))
|
||||
};
|
||||
this.dialog.close(context);
|
||||
}
|
||||
|
||||
private patch(index: number, changes: Partial<ProjectContextEntry>) {
|
||||
this.entries.update((entries) =>
|
||||
entries.map((entry, i) => (i === index ? { ...entry, ...changes } : entry)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
.project-delete__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.project-delete__warning {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.project-delete__warning p {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.project-delete__finalized {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-delete__flows {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.project-delete__flows li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.project-delete__flow-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-delete__badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.project-delete__more {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.project-delete__confirm {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-delete__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.project-delete__confirm-button:not([disabled]) {
|
||||
background-color: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
@if (state(); as dialogState) {
|
||||
<app-modal-shell
|
||||
[title]="headline()"
|
||||
subtitle="This cannot be undone."
|
||||
ariaLabel="Confirm project deletion"
|
||||
maxWidth="560px"
|
||||
closeLabel="Cancel"
|
||||
(backdropClick)="cancel()"
|
||||
(closeClick)="cancel()">
|
||||
|
||||
<div class="project-delete__body">
|
||||
@if (flowCount() > 0) {
|
||||
<div class="project-delete__warning">
|
||||
<mat-icon fontIcon="warning"></mat-icon>
|
||||
<div>
|
||||
<p>
|
||||
Deleting this project also permanently deletes the
|
||||
{{ flowCount() === 1 ? 'flow' : flowCount() + ' flows' }} it contains.
|
||||
</p>
|
||||
@if (finalizedCount() > 0) {
|
||||
<p class="project-delete__finalized">
|
||||
{{ finalizedCount() }} of them
|
||||
{{ finalizedCount() === 1 ? 'is finalized and would' : 'are finalized and would' }}
|
||||
normally be protected from deletion.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="project-delete__flows">
|
||||
@for (flow of listedFlows(); track flow.id) {
|
||||
<li>
|
||||
<span class="project-delete__flow-name">{{ flow.name }}</span>
|
||||
@if (flow.finalized) {
|
||||
<span class="project-delete__badge">finalized</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
@if (hiddenFlowCount() > 0) {
|
||||
<li class="project-delete__more">+ {{ hiddenFlowCount() }} more</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>This project contains no flows.</p>
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline" class="project-delete__confirm">
|
||||
<mat-label>Type “{{ dialogState.project.name }}” to confirm</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="typedName()"
|
||||
(ngModelChange)="typedName.set($event)"
|
||||
name="projectNameConfirmation"
|
||||
autocomplete="off" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<footer class="project-delete__footer">
|
||||
<button type="button" mat-stroked-button (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-flat-button
|
||||
color="warn"
|
||||
class="project-delete__confirm-button"
|
||||
[disabled]="!canConfirm()"
|
||||
(click)="confirm()">
|
||||
Delete project
|
||||
</button>
|
||||
</footer>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { Project } from '@models/project';
|
||||
import { ProjectDeleteDialogService } from '@services/dialogs/project-delete-dialog';
|
||||
|
||||
import { ProjectDeleteDialogComponent } from './project-delete-dialog';
|
||||
|
||||
const project: Project = {
|
||||
id: 'p1',
|
||||
name: 'Recruiting',
|
||||
owner: 'alice',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
sharedContext: { entries: [] }
|
||||
};
|
||||
|
||||
async function open(flows: { id: string; name: string; finalized?: boolean }[]) {
|
||||
await TestBed.configureTestingModule({ imports: [ProjectDeleteDialogComponent] }).compileComponents();
|
||||
|
||||
const dialog = TestBed.inject(ProjectDeleteDialogService);
|
||||
const fixture = TestBed.createComponent(ProjectDeleteDialogComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const result = dialog.open({ project, flows });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
return { fixture, dialog, result };
|
||||
}
|
||||
|
||||
describe('ProjectDeleteDialogComponent', () => {
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('keeps the destructive button disabled until the exact project name is typed', async () => {
|
||||
const { fixture } = await open([{ id: 'f1', name: 'Flow A' }]);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.canConfirm()).toBe(false);
|
||||
|
||||
component.typedName.set('recruiting');
|
||||
expect(component.canConfirm()).toBe(false);
|
||||
|
||||
component.typedName.set('Recruiting');
|
||||
expect(component.canConfirm()).toBe(true);
|
||||
|
||||
const button: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.project-delete__confirm-button');
|
||||
fixture.detectChanges();
|
||||
expect(button.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('names the count and warns that finalized flows go too', async () => {
|
||||
const { fixture } = await open([
|
||||
{ id: 'f1', name: 'Flow A' },
|
||||
{ id: 'f2', name: 'Flow B', finalized: true }
|
||||
]);
|
||||
|
||||
expect(fixture.componentInstance.headline()).toBe('Delete project “Recruiting” and its 2 flows?');
|
||||
expect(fixture.componentInstance.finalizedCount()).toBe(1);
|
||||
|
||||
const text: string = fixture.nativeElement.textContent;
|
||||
// The cascade breaks a rule the flow list otherwise enforces, so it must be spelled out.
|
||||
expect(text).toContain('finalized');
|
||||
expect(text).toContain('Flow A');
|
||||
expect(text).toContain('Flow B');
|
||||
});
|
||||
|
||||
it('resolves false when cancelled', async () => {
|
||||
const { fixture, result } = await open([{ id: 'f1', name: 'Flow A' }]);
|
||||
|
||||
fixture.componentInstance.cancel();
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves true only once the name matches', async () => {
|
||||
const { fixture, result } = await open([{ id: 'f1', name: 'Flow A' }]);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.confirm();
|
||||
expect(component.state()).not.toBeNull();
|
||||
|
||||
component.typedName.set('Recruiting');
|
||||
component.confirm();
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('still asks for an empty project, but does not demand the name since nothing is destroyed', async () => {
|
||||
const { fixture } = await open([]);
|
||||
|
||||
expect(fixture.componentInstance.flowCount()).toBe(0);
|
||||
expect(fixture.nativeElement.textContent).toContain('contains no flows');
|
||||
expect(fixture.componentInstance.canConfirm()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { ProjectDeleteDialogService } from '@services/dialogs/project-delete-dialog';
|
||||
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
|
||||
|
||||
const MAX_LISTED_FLOWS = 8;
|
||||
|
||||
/**
|
||||
* Confirmation for a cascading project delete. Deliberately harder to dismiss than an ordinary
|
||||
* confirm: it names the count, lists the flows, says that finalized flows go too - which the flow
|
||||
* list otherwise forbids - and requires the project name to be typed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-project-delete-dialog',
|
||||
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, ModalShellComponent],
|
||||
templateUrl: './project-delete-dialog.html',
|
||||
styleUrl: './project-delete-dialog.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProjectDeleteDialogComponent {
|
||||
private readonly dialog = inject(ProjectDeleteDialogService);
|
||||
|
||||
readonly state = this.dialog.state;
|
||||
readonly typedName = signal('');
|
||||
|
||||
readonly flowCount = computed(() => this.state()?.flows.length ?? 0);
|
||||
readonly finalizedCount = computed(() => this.state()?.flows.filter((flow) => flow.finalized).length ?? 0);
|
||||
readonly listedFlows = computed(() => (this.state()?.flows ?? []).slice(0, MAX_LISTED_FLOWS));
|
||||
readonly hiddenFlowCount = computed(() => Math.max(0, this.flowCount() - MAX_LISTED_FLOWS));
|
||||
|
||||
readonly headline = computed(() => {
|
||||
const project = this.state()?.project;
|
||||
if (!project) return '';
|
||||
const count = this.flowCount();
|
||||
return count === 0
|
||||
? `Delete project “${project.name}”?`
|
||||
: `Delete project “${project.name}” and its ${count} ${count === 1 ? 'flow' : 'flows'}?`;
|
||||
});
|
||||
|
||||
readonly canConfirm = computed(() => {
|
||||
const project = this.state()?.project;
|
||||
if (!project) return false;
|
||||
// Nothing is destroyed when the project is empty, so typing the name would be busywork.
|
||||
if (this.flowCount() === 0) return true;
|
||||
return this.typedName().trim() === project.name;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Clear the typed name each time the dialog opens, so a previous confirmation cannot carry over.
|
||||
effect(() => {
|
||||
this.state();
|
||||
this.typedName.set('');
|
||||
});
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dialog.close(false);
|
||||
}
|
||||
|
||||
confirm() {
|
||||
if (!this.canConfirm()) return;
|
||||
this.dialog.close(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
.project-dialog__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.project-dialog__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
@if (state(); as dialogState) {
|
||||
<app-modal-shell
|
||||
[title]="isEdit() ? 'Edit project' : 'New project'"
|
||||
[subtitle]="isEdit() ? 'Rename the project or change its description.' : 'Group related flows under one project.'"
|
||||
ariaLabel="Project details"
|
||||
maxWidth="520px"
|
||||
closeLabel="Cancel"
|
||||
(backdropClick)="cancel()"
|
||||
(closeClick)="cancel()">
|
||||
|
||||
<form class="project-dialog__form" (submit)="submit($event)">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput [field]="projectForm.name" placeholder="e.g. Recruiting" />
|
||||
@if (isInvalid(projectForm.name())) {
|
||||
<mat-error>{{ projectForm.name().errors()[0].message }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Description (optional)</mat-label>
|
||||
<textarea matInput rows="3" [field]="projectForm.description"></textarea>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<footer class="project-dialog__footer">
|
||||
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
|
||||
<button type="button" mat-flat-button color="primary" [disabled]="!canSubmit()" (click)="submit($event)">
|
||||
{{ isEdit() ? 'Save' : 'Create project' }}
|
||||
</button>
|
||||
</footer>
|
||||
</app-modal-shell>
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Field, form, required } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { ProjectDialogService } from '@services/dialogs/project-dialog';
|
||||
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
|
||||
import { FormUtility } from '@utilities/form-utility';
|
||||
|
||||
/** Host for the create/edit project dialog. Mounted once in app.html. */
|
||||
@Component({
|
||||
selector: 'app-project-dialog',
|
||||
imports: [FormsModule, Field, MatButtonModule, MatFormFieldModule, MatInputModule, ModalShellComponent],
|
||||
templateUrl: './project-dialog.html',
|
||||
styleUrl: './project-dialog.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ProjectDialogComponent extends FormUtility {
|
||||
private readonly dialog = inject(ProjectDialogService);
|
||||
|
||||
readonly state = this.dialog.state;
|
||||
readonly isEdit = computed(() => !!this.state()?.project);
|
||||
|
||||
readonly model = signal({ name: '', description: '' });
|
||||
|
||||
readonly projectForm = form(this.model, (model) => {
|
||||
required(model.name, { message: 'Name is required' });
|
||||
});
|
||||
|
||||
readonly canSubmit = computed(() => !this.projectForm().invalid());
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Seed the fields whenever the dialog opens, so editing starts from the current values.
|
||||
effect(() => {
|
||||
const project = this.state()?.project;
|
||||
this.model.set({ name: project?.name ?? '', description: project?.description ?? '' });
|
||||
});
|
||||
}
|
||||
|
||||
cancel(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
this.dialog.close(null);
|
||||
}
|
||||
|
||||
submit(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (!this.canSubmit()) return;
|
||||
|
||||
const { name, description } = this.model();
|
||||
this.dialog.close({ name: name.trim(), description: description.trim() });
|
||||
}
|
||||
}
|
||||
|
|
@ -399,6 +399,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
const activeAnnotationIdsByNode = this.execution()?.biasExecutionContext?.activeAnnotationIdsByNode ?? {};
|
||||
const globalInputsValue = this.execution()?.context.globalInputs ?? {};
|
||||
const executionVariablesValue = this.execution()?.context.executionVariables ?? {};
|
||||
const projectContextValue = this.execution()?.context.projectContext ?? {};
|
||||
const executionName = this.execution()?.name ?? null;
|
||||
const steps = this.stepsArray();
|
||||
const execution = this.execution();
|
||||
|
|
@ -458,6 +459,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
__biasActiveAnnotationIds: activeAnnotationIdsByNode[step.id] ?? [],
|
||||
__globalInputs: globalInputsValue,
|
||||
__executionVariables: executionVariablesValue,
|
||||
__projectContext: projectContextValue,
|
||||
__executionName: executionName
|
||||
},
|
||||
position: stepNode.position ?? {
|
||||
|
|
@ -508,6 +510,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
__biasActiveAnnotationIds: activeAnnotationIdsByNode[sourceNode.id] ?? [],
|
||||
__globalInputs: globalInputsValue,
|
||||
__executionVariables: executionVariablesValue,
|
||||
__projectContext: projectContextValue,
|
||||
__executionName: executionName
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -283,3 +283,15 @@
|
|||
.tasks-list-latest:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Clusters the sibling groups produced by one project run. */
|
||||
.tasks-list-project-chip {
|
||||
display: inline-block;
|
||||
margin-right: 0.375rem;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: #d8f4e7;
|
||||
color: #14532d;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@
|
|||
<mat-icon class="tasks-list-group-chevron" [fontIcon]="isGroupExpanded(group.id) ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<div class="tasks-list-group-main">
|
||||
<div class="tasks-list-group-title">{{ group.name }}</div>
|
||||
<div class="tasks-list-group-subtitle">{{ group.sourceFlowId }}</div>
|
||||
<div class="tasks-list-group-subtitle">
|
||||
@if (group.projectName) {
|
||||
<span class="tasks-list-project-chip">{{ group.projectName }}</span>
|
||||
}
|
||||
<span>{{ group.sourceFlowId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tasks-list-group-summary">
|
||||
<span class="tasks-list-count">{{ group.executionCount }} runs</span>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ export type TaskExecutionGroupListItem = {
|
|||
latestExecutionId: string;
|
||||
latestStatus: TaskExecutionStatus;
|
||||
latestRunNumber: number | null;
|
||||
/**
|
||||
* Name of the project the group's flow belongs to, derived by the host. Execution groups have a
|
||||
* single level and are keyed by source flow, so a project run shows up as N sibling groups: the
|
||||
* chip is what visually clusters them.
|
||||
*/
|
||||
projectName?: string | null;
|
||||
executions: TaskExecutionListItem[];
|
||||
};
|
||||
|
||||
|
|
@ -69,7 +75,9 @@ export class TasksExecutionsListComponent {
|
|||
{ field: 'name', label: 'Name' },
|
||||
{ field: 'executionCount', label: 'Executions' },
|
||||
{ field: 'lastExecutionTime', label: 'Latest Run' },
|
||||
{ field: 'latestStatus', label: 'Status' }
|
||||
{ field: 'latestStatus', label: 'Status' },
|
||||
// Sorting by project keeps the sibling groups of one project run adjacent.
|
||||
{ field: 'projectName', label: 'Project' }
|
||||
];
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
|
|
@ -85,6 +93,7 @@ export class TasksExecutionsListComponent {
|
|||
return (
|
||||
group.name.toLowerCase().includes(term) ||
|
||||
group.sourceFlowId.toLowerCase().includes(term) ||
|
||||
(group.projectName ?? '').toLowerCase().includes(term) ||
|
||||
group.id.toLowerCase().includes(term) ||
|
||||
group.executions.some((execution) =>
|
||||
execution.title.toLowerCase().includes(term) ||
|
||||
|
|
|
|||
|
|
@ -97,3 +97,42 @@ describe('resolveTemplateSegments', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateSubstitutions project namespace', () => {
|
||||
it('exposes project values under the project prefix', () => {
|
||||
const values = buildTemplateSubstitutions(null, null, null, undefined, { tone: 'formal' });
|
||||
|
||||
expect(values['project.tone']).toBe('formal');
|
||||
});
|
||||
|
||||
it('keeps every namespace distinct, so no source shadows another', () => {
|
||||
// Mirrors the backend: project., global., vars. and a bare input are four different keys.
|
||||
const values = buildTemplateSubstitutions(
|
||||
{ tone: 'from-input' },
|
||||
{ tone: 'from-global' },
|
||||
{ tone: 'from-vars' },
|
||||
undefined,
|
||||
{ tone: 'from-project' }
|
||||
);
|
||||
|
||||
expect(values['tone']).toBe('from-input');
|
||||
expect(values['global.tone']).toBe('from-global');
|
||||
expect(values['vars.tone']).toBe('from-vars');
|
||||
expect(values['project.tone']).toBe('from-project');
|
||||
});
|
||||
|
||||
it('adds nothing when a flow has no project', () => {
|
||||
const values = buildTemplateSubstitutions({ a: 1 }, null, null);
|
||||
|
||||
expect(Object.keys(values).some((key) => key.startsWith('project.'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a project placeholder in text', () => {
|
||||
const segments = resolveTemplateSegments(
|
||||
'Write in a ${{project.tone}} tone',
|
||||
buildTemplateSubstitutions(null, null, null, undefined, { tone: 'formal' })
|
||||
);
|
||||
|
||||
expect(segments.map((segment) => segment.value ?? segment.text).join('')).toContain('formal');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export function buildTemplateSubstitutions(
|
|||
inputs: Record<string, unknown> | null | undefined,
|
||||
globalInputs: Record<string, unknown> | null | undefined,
|
||||
executionVariables: Record<string, unknown> | null | undefined,
|
||||
context?: TemplateContextMeta
|
||||
context?: TemplateContextMeta,
|
||||
projectVariables?: Record<string, unknown> | null
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
|
||||
|
|
@ -39,6 +40,11 @@ export function buildTemplateSubstitutions(
|
|||
for (const [name, value] of Object.entries(executionVariables ?? {})) {
|
||||
values[`vars.${name}`] = value;
|
||||
}
|
||||
// Mirrors the backend namespace: project.x, global.x, vars.x and a bare input are distinct keys,
|
||||
// so no source can shadow another.
|
||||
for (const [name, value] of Object.entries(projectVariables ?? {})) {
|
||||
values[`project.${name}`] = value;
|
||||
}
|
||||
if (context?.executionId) {
|
||||
values['context.executionId'] = context.executionId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -547,3 +547,44 @@
|
|||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Read-only view of the values inherited from the flow's project. */
|
||||
.title-toolbar-project-context {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
.title-toolbar-project-context-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #475569;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title-toolbar-project-context-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.title-toolbar-project-context-name {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.title-toolbar-project-context-type {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.title-toolbar-project-context-hint {
|
||||
margin-top: 0.25rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,31 @@
|
|||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (projectsEnabled && inheritedProject(); as project) {
|
||||
<div class="title-toolbar-project-context">
|
||||
<div class="title-toolbar-project-context-header">
|
||||
<mat-icon fontIcon="folder_shared"></mat-icon>
|
||||
<span>Inherited from project “{{ project.name }}”</span>
|
||||
</div>
|
||||
@if (!inheritedProjectEntries().length) {
|
||||
<div class="title-toolbar-global-inputs-empty">This project shares no values.</div>
|
||||
} @else {
|
||||
@for (entry of inheritedProjectEntries(); track entry.name) {
|
||||
<div class="title-toolbar-project-context-row">
|
||||
<span class="title-toolbar-project-context-name">{{ entry.name }}</span>
|
||||
<span class="title-toolbar-project-context-type">{{ entry.type }}</span>
|
||||
</div>
|
||||
<div class="title-toolbar-global-syntax">
|
||||
Available references: <code>{{ projectTemplateReference(entry.name) }}</code> and <code>{{ projectSpelReference(entry.name) }}</code>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="title-toolbar-project-context-hint">
|
||||
Edit these in the flows list, from the project's menu.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,15 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
|||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { FlowGlobalInput, FlowLane } from '@models/flow';
|
||||
import { projectTemplateReference } from '@models/project';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { ProjectsService } from '@services/projects/projects';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { take } from 'rxjs';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { SWIMLANES_ENABLED } from '@shared/feature-flags';
|
||||
import { PROJECTS_ENABLED, SWIMLANES_ENABLED } from '@shared/feature-flags';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-toolbar',
|
||||
|
|
@ -36,6 +38,9 @@ export class TitleToolbar {
|
|||
private router = inject(Router);
|
||||
private blocksService = inject(BlocksService);
|
||||
private flowsService = inject(FlowsService);
|
||||
private projectsService = inject(ProjectsService);
|
||||
|
||||
readonly projectsEnabled = PROJECTS_ENABLED;
|
||||
private authorization = inject(Authorization);
|
||||
private taskExecutionsService = inject(TaskExecutionsService);
|
||||
flow = computed(() => this.editorState.currentFlow());
|
||||
|
|
@ -231,6 +236,26 @@ export class TitleToolbar {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The values this flow inherits from its project, read-only. Shown here because this is exactly
|
||||
* where prompts are written, so the ${{project.x}} placeholders are discoverable at the point of
|
||||
* use. Editing them lives with the project, in the flows list.
|
||||
*/
|
||||
readonly inheritedProject = computed(() => {
|
||||
const projectId = this.flow()?.projectId;
|
||||
return projectId ? this.projectsService.projectById().get(projectId) ?? null : null;
|
||||
});
|
||||
|
||||
readonly inheritedProjectEntries = computed(() => this.inheritedProject()?.sharedContext.entries ?? []);
|
||||
|
||||
projectTemplateReference(name: string): string {
|
||||
return projectTemplateReference(name.trim() || 'name');
|
||||
}
|
||||
|
||||
projectSpelReference(name: string): string {
|
||||
return `#project['${name.trim() || 'name'}']`;
|
||||
}
|
||||
|
||||
globalTemplateReference(name: string): string {
|
||||
const resolved = name.trim() || 'name';
|
||||
return `\${{global.${resolved}}}`;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export class ListView<T> {
|
|||
filter?: string;
|
||||
list?: Signal<T[]>;
|
||||
order: OrderViewState = new OrderViewState();
|
||||
/**
|
||||
* Which collapsible groups the user has expanded. Explicit fields rather than a generic bag,
|
||||
* because ListView already carries domain-shaped state and these keep their types.
|
||||
*/
|
||||
expandedGroupIds?: Set<string>;
|
||||
/** A second, independent filter dimension - `filter` is already taken by the primary toggle. */
|
||||
secondaryFilter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ExecutionVaultCredentialsCallServiceFake } from "@services/llm-provider
|
|||
import { LlmProviderCallServiceFake } from "@services/llm-provider/llm-provider-call.fake";
|
||||
import { VaultCallServiceFake } from "@services/vault/vault-call.fake";
|
||||
import { FlowsCallServiceFake } from "@services/flows/flows-call.fake";
|
||||
import { ProjectsCallServiceFake } from "@services/projects/projects-call.fake";
|
||||
import { FieldRetrieverCallServiceFake } from "@services/retriever/field-retriever-call.fake";
|
||||
import { TaskExecutionsCallServiceFake } from "@services/task-executions/task-executions-call.fake";
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ export const environment = {
|
|||
adminCallService: AdminCallFakeService,
|
||||
assistantCallService: AssistantCallServiceFake,
|
||||
flowsCallService: FlowsCallServiceFake,
|
||||
projectsCallService: ProjectsCallServiceFake,
|
||||
blocksCallService: BlocksCallServiceFake,
|
||||
containersCallService: ContainersCallServiceFake,
|
||||
fieldRetrieverCallService: FieldRetrieverCallServiceFake,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/exe
|
|||
import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call";
|
||||
import { VaultCallService } from "@services/vault/vault-call";
|
||||
import { FlowsCallService } from "@services/flows/flows-call";
|
||||
import { ProjectsCallService } from "@services/projects/projects-call";
|
||||
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
|
||||
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ export const environment = {
|
|||
authorizationCallService: AuthorizationCallService,
|
||||
adminCallService: AdminCallService,
|
||||
flowsCallService: FlowsCallService,
|
||||
projectsCallService: ProjectsCallService,
|
||||
blocksCallService: BlocksCallService,
|
||||
containersCallService: ContainersCallService,
|
||||
fieldRetrieverCallService: FieldRetrieverCallService,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ExecutionVaultCredentialsCallService } from "@services/llm-provider/exe
|
|||
import { LlmProviderCallService } from "@services/llm-provider/llm-provider-call";
|
||||
import { VaultCallService } from "@services/vault/vault-call";
|
||||
import { FlowsCallService } from "@services/flows/flows-call";
|
||||
import { ProjectsCallService } from "@services/projects/projects-call";
|
||||
import { FieldRetrieverCallService } from "@services/retriever/field-retriever-call";
|
||||
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ export const environment = {
|
|||
adminCallService: AdminCallService,
|
||||
assistantCallService: AssistantCallService,
|
||||
flowsCallService: FlowsCallService,
|
||||
projectsCallService: ProjectsCallService,
|
||||
blocksCallService: BlocksCallService,
|
||||
containersCallService: ContainersCallService,
|
||||
fieldRetrieverCallService: FieldRetrieverCallService,
|
||||
|
|
|
|||
Loading…
Reference in New Issue