diff --git a/src/app/app.html b/src/app/app.html index 295075f..501c7f9 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -7,3 +7,6 @@ + + + diff --git a/src/app/app.ts b/src/app/app.ts index 334b091..2630d20 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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 diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index c33c36f..141fc7a 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -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, diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 7d39e72..347445e 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -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[]; }; diff --git a/src/app/models/project.ts b/src/app/models/project.ts new file mode 100644 index 0000000..96427c4 --- /dev/null +++ b/src/app/models/project.ts @@ -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.}}`, 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 & { 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; + const rawEntries = Array.isArray(value['entries']) ? value['entries'] : []; + + const entries = rawEntries + .filter((entry): entry is Record => + !!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 }[]; +}; diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index 35b7a11..b748cac 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -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; globalInputs?: Record; globalInputDescriptors?: Record; + /** + * Values inherited from the flow's project, frozen when the run was created. Read in prompts as + * `${{project.}}`. + */ + projectContext?: Record; result: Record; partialResult?: Record; startTime?: number | null; diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.html b/src/app/pages/main/editor-sidebar/editor-sidebar.html index cc288ce..ff50102 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.html +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.html @@ -75,6 +75,12 @@ Create with AI + @if (projectsEnabled) { + + } } @else { + @if (projectsEnabled) { + + + @for (project of assignableProjects(); track project.id) { + + } + @if (assignableProjects().length === 0) { + + } + + + } + + @if (!isUngrouped()) { + + + + + + + + + + + } + + + @if (expanded()) { +
+ @for (flow of flows(); track flow.id) { +
+ @if (canReorder()) { +
+ + +
+ } + +
+ } +
+ } + diff --git a/src/app/shared/flows-list/flows-group/flows-group.spec.ts b/src/app/shared/flows-list/flows-group/flows-group.spec.ts new file mode 100644 index 0000000..f97c10f --- /dev/null +++ b/src/app/shared/flows-list/flows-group/flows-group.spec.ts @@ -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); + }); +}); diff --git a/src/app/shared/flows-list/flows-group/flows-group.ts b/src/app/shared/flows-list/flows-group/flows-group.ts new file mode 100644 index 0000000..5c5a691 --- /dev/null +++ b/src/app/shared/flows-list/flows-group/flows-group.ts @@ -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(null); + readonly flows = input.required(); + readonly expanded = input(false); + readonly detailOpenedId = model(null); + + readonly toggled = output(); + readonly editRequested = output(); + readonly contextRequested = output(); + readonly deleteRequested = output(); + readonly newFlowRequested = output(); + readonly runRequested = output(); + 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(); + } +} diff --git a/src/app/shared/flows-list/flows-list.css b/src/app/shared/flows-list/flows-list.css index a9c9ff8..d7522db 100644 --- a/src/app/shared/flows-list/flows-list.css +++ b/src/app/shared/flows-list/flows-list.css @@ -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; +} diff --git a/src/app/shared/flows-list/flows-list.html b/src/app/shared/flows-list/flows-list.html index 45af005..9115243 100644 --- a/src/app/shared/flows-list/flows-list.html +++ b/src/app/shared/flows-list/flows-list.html @@ -22,10 +22,44 @@
+ + @if (projectsEnabled && projects().length > 0) { +
+ + Project + + All + @for (project of projects(); track project.id) { + {{ project.name }} + } + No project + + +
+ } + @if (orderedFlows().length === 0) {
No flows found
+ } @else if (groupingActive()) { +
+ @for (group of groupedFlows(); track group.key) { + + + } +
} @else { @for (flow of orderedFlows(); track flow.id) { diff --git a/src/app/shared/flows-list/flows-list.spec.ts b/src/app/shared/flows-list/flows-list.spec.ts index 6dbe5da..44bfd29 100644 --- a/src/app/shared/flows-list/flows-list.spec.ts +++ b/src/app/shared/flows-list/flows-list.spec.ts @@ -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; +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; + startProjectRun: ReturnType; +}; + +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 = 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(); }); }); diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts index 9099b9c..b11ec09 100644 --- a/src/app/shared/flows-list/flows-list.ts +++ b/src/app/shared/flows-list/flows-list.ts @@ -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 { searchTerm = model(''); 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 | */ + readonly projectFilter = signal(ALL_PROJECTS); loading: WritableSignal = signal(true); @@ -48,6 +81,8 @@ export class FlowsList extends ListStateViewHolder { detailOpenedId = signal(null); + private readonly expandedGroupIds = signal>(new Set()); + constructor() { super('flowsList', {defaultOrder: { orderBy: 'name', orderDir: 'asc' } as OrderViewState, defaultFilter: 'all'}); effect(() => { @@ -57,6 +92,16 @@ export class FlowsList extends ListStateViewHolder { } 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()); + 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 { 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 { }); + /** + * 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); diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index 8f9bdb1..817f2e4 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -670,6 +670,11 @@ export class TaskStepNodeComponent { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } + private projectContextValue(): Record { + const value = this.blockConfiguration?.['__projectContext']; + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + } + 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 { diff --git a/src/app/shared/project-context-dialog/project-context-dialog.css b/src/app/shared/project-context-dialog/project-context-dialog.css new file mode 100644 index 0000000..1f4bc05 --- /dev/null +++ b/src/app/shared/project-context-dialog/project-context-dialog.css @@ -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; +} diff --git a/src/app/shared/project-context-dialog/project-context-dialog.html b/src/app/shared/project-context-dialog/project-context-dialog.html new file mode 100644 index 0000000..4677590 --- /dev/null +++ b/src/app/shared/project-context-dialog/project-context-dialog.html @@ -0,0 +1,89 @@ +@if (state(); as dialogState) { + + +
+ @if (entries().length === 0) { +

No shared variables defined.

+ } @else { +
+ @for (entry of entries(); track $index) { +
+ + Name + + @if (validationErrors()[$index]; as error) { + {{ error }} + } + + + + Type + + @for (type of entryTypes; track type) { + {{ type }} + } + + + + + Value + + + + + + +
+ + @if (entry.name && !validationErrors()[$index]) { + {{ templateReference(entry.name) }} + } + } +
+ } + + +
+ +
+ + +
+
+} diff --git a/src/app/shared/project-context-dialog/project-context-dialog.ts b/src/app/shared/project-context-dialog/project-context-dialog.ts new file mode 100644 index 0000000..b940cbe --- /dev/null +++ b/src/app/shared/project-context-dialog/project-context-dialog.ts @@ -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([]); + + 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(); + + 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) { + this.entries.update((entries) => + entries.map((entry, i) => (i === index ? { ...entry, ...changes } : entry))); + } +} diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.css b/src/app/shared/project-delete-dialog/project-delete-dialog.css new file mode 100644 index 0000000..f9c8087 --- /dev/null +++ b/src/app/shared/project-delete-dialog/project-delete-dialog.css @@ -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; +} diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.html b/src/app/shared/project-delete-dialog/project-delete-dialog.html new file mode 100644 index 0000000..4972f13 --- /dev/null +++ b/src/app/shared/project-delete-dialog/project-delete-dialog.html @@ -0,0 +1,71 @@ +@if (state(); as dialogState) { + + +
+ @if (flowCount() > 0) { +
+ +
+

+ Deleting this project also permanently deletes the + {{ flowCount() === 1 ? 'flow' : flowCount() + ' flows' }} it contains. +

+ @if (finalizedCount() > 0) { +

+ {{ finalizedCount() }} of them + {{ finalizedCount() === 1 ? 'is finalized and would' : 'are finalized and would' }} + normally be protected from deletion. +

+ } +
+
+ +
    + @for (flow of listedFlows(); track flow.id) { +
  • + {{ flow.name }} + @if (flow.finalized) { + finalized + } +
  • + } + @if (hiddenFlowCount() > 0) { +
  • + {{ hiddenFlowCount() }} more
  • + } +
+ } @else { +

This project contains no flows.

+ } + + + Type “{{ dialogState.project.name }}” to confirm + + +
+ +
+ + +
+
+} diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.spec.ts b/src/app/shared/project-delete-dialog/project-delete-dialog.spec.ts new file mode 100644 index 0000000..70db20c --- /dev/null +++ b/src/app/shared/project-delete-dialog/project-delete-dialog.spec.ts @@ -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); + }); +}); diff --git a/src/app/shared/project-delete-dialog/project-delete-dialog.ts b/src/app/shared/project-delete-dialog/project-delete-dialog.ts new file mode 100644 index 0000000..ebdfcf1 --- /dev/null +++ b/src/app/shared/project-delete-dialog/project-delete-dialog.ts @@ -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); + } +} diff --git a/src/app/shared/project-dialog/project-dialog.css b/src/app/shared/project-dialog/project-dialog.css new file mode 100644 index 0000000..878e059 --- /dev/null +++ b/src/app/shared/project-dialog/project-dialog.css @@ -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; +} diff --git a/src/app/shared/project-dialog/project-dialog.html b/src/app/shared/project-dialog/project-dialog.html new file mode 100644 index 0000000..12dc9fc --- /dev/null +++ b/src/app/shared/project-dialog/project-dialog.html @@ -0,0 +1,33 @@ +@if (state(); as dialogState) { + + +
+ + Name + + @if (isInvalid(projectForm.name())) { + {{ projectForm.name().errors()[0].message }} + } + + + + Description (optional) + + +
+ +
+ + +
+
+} diff --git a/src/app/shared/project-dialog/project-dialog.ts b/src/app/shared/project-dialog/project-dialog.ts new file mode 100644 index 0000000..29fa4c1 --- /dev/null +++ b/src/app/shared/project-dialog/project-dialog.ts @@ -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() }); + } +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index e3e3095..df47459 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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 } }; diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.css b/src/app/shared/tasks-executions-list/tasks-executions-list.css index 1ad8235..6f0568f 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.css +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.css @@ -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; +} diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.html b/src/app/shared/tasks-executions-list/tasks-executions-list.html index ee2681c..f5d676c 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.html +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.html @@ -38,7 +38,12 @@
{{ group.name }}
-
{{ group.sourceFlowId }}
+
+ @if (group.projectName) { + {{ group.projectName }} + } + {{ group.sourceFlowId }} +
{{ group.executionCount }} runs diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index cf0ed2d..29e6701 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -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) || diff --git a/src/app/shared/template-placeholder-text/template-placeholder.spec.ts b/src/app/shared/template-placeholder-text/template-placeholder.spec.ts index 318ca8f..3d03948 100644 --- a/src/app/shared/template-placeholder-text/template-placeholder.spec.ts +++ b/src/app/shared/template-placeholder-text/template-placeholder.spec.ts @@ -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'); + }); +}); diff --git a/src/app/shared/template-placeholder-text/template-placeholder.ts b/src/app/shared/template-placeholder-text/template-placeholder.ts index 072751c..adffc25 100644 --- a/src/app/shared/template-placeholder-text/template-placeholder.ts +++ b/src/app/shared/template-placeholder-text/template-placeholder.ts @@ -26,7 +26,8 @@ export function buildTemplateSubstitutions( inputs: Record | null | undefined, globalInputs: Record | null | undefined, executionVariables: Record | null | undefined, - context?: TemplateContextMeta + context?: TemplateContextMeta, + projectVariables?: Record | null ): Record { const values: Record = {}; @@ -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; } diff --git a/src/app/shared/title-toolbar/title-toolbar.css b/src/app/shared/title-toolbar/title-toolbar.css index 1375120..030170a 100644 --- a/src/app/shared/title-toolbar/title-toolbar.css +++ b/src/app/shared/title-toolbar/title-toolbar.css @@ -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; +} diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html index 653f17e..7b6ed4d 100644 --- a/src/app/shared/title-toolbar/title-toolbar.html +++ b/src/app/shared/title-toolbar/title-toolbar.html @@ -222,6 +222,31 @@
} } + + @if (projectsEnabled && inheritedProject(); as project) { +
+
+ + Inherited from project “{{ project.name }}” +
+ @if (!inheritedProjectEntries().length) { +
This project shares no values.
+ } @else { + @for (entry of inheritedProjectEntries(); track entry.name) { +
+ {{ entry.name }} + {{ entry.type }} +
+
+ Available references: {{ projectTemplateReference(entry.name) }} and {{ projectSpelReference(entry.name) }} +
+ } + } +
+ Edit these in the flows list, from the project's menu. +
+
+ } } diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index f2a8843..93c35d6 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -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}}}`; diff --git a/src/app/utilities/list-state-holder.ts b/src/app/utilities/list-state-holder.ts index 4a7bd36..7873ee0 100644 --- a/src/app/utilities/list-state-holder.ts +++ b/src/app/utilities/list-state-holder.ts @@ -16,6 +16,13 @@ export class ListView { filter?: string; list?: Signal; 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; + /** A second, independent filter dimension - `filter` is already taken by the primary toggle. */ + secondaryFilter?: string; } /** diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index e6c44e9..72c33e5 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -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, diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index fe1775a..03c86bf 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -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, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index bfd98a8..8d6d3cb 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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,