feat: improve auth flow and task execution UX

This commit is contained in:
Lucio Lelii 2026-04-09 14:01:56 +02:00
parent bb1cdd52ac
commit 7028ac0c53
61 changed files with 1674 additions and 245 deletions

531
docs/user-guide.md Normal file
View File

@ -0,0 +1,531 @@
# Guida Utente HumAIn Flow
## Introduzione
HumAIn Flow e un'applicazione web per progettare, validare ed eseguire workflow composti da blocchi logici, connessioni dati e dipendenze di esecuzione.
L'app supporta due modi principali di lavoro:
- costruzione manuale del flow nell'editor visuale
- generazione o modifica del flow tramite assistente
Una volta creato il flow, puoi salvarlo, controllarne gli errori di validazione ed eseguirlo nel tab `Tasks`.
## Accesso all'applicazione
Dopo il login entri nella schermata principale. La barra superiore contiene:
- `Editor`: area in cui si progettano i flow
- `Tasks`: area in cui si consultano ed eseguono le istanze dei flow
- menu utente: operazioni personali come cambio password
Se il tuo account ha accesso ad aree aggiuntive, queste compaiono nel menu utente. Questa guida si concentra sull'uso standard dell'editor e delle esecuzioni.
## Struttura generale dell'interfaccia
### Editor
Nel tab `Editor` trovi tre aree principali:
- pannello sinistro con elenco di `Blocks` e `Containers`
- canvas centrale del workflow
- pannello destro con `Assistant` ed eventuali `Errors`
### Tasks
Nel tab `Tasks` trovi:
- lista delle esecuzioni nella colonna sinistra
- dettaglio della singola esecuzione nella parte centrale
## Concetti base
### Flow
Un flow e la definizione del workflow. Contiene:
- blocchi
- container
- connessioni dati
- dipendenze di esecuzione
### Block
Un block rappresenta un singolo step del workflow. Alcuni esempi tipici:
- blocchi LLM
- blocchi di input o output
- blocchi di interazione umana
- blocchi condizionali
### Container
Un container contiene un `subFlow`, cioe un flow annidato. Serve per raggruppare una parte del workflow in un'unita riutilizzabile o piu leggibile.
Un container senza `subFlow` e considerato incompleto.
### Connessioni dati
Le connessioni standard collegano:
- un output sorgente
- a un input destinazione
Servono per trasferire valori tra nodi.
### Dipendenze di esecuzione
Le dipendenze non trasferiscono dati. Impongono solo un ordine di esecuzione.
Le label visibili sui nodi sono:
- `Depends on`: questo nodo deve aspettare un altro nodo
- `Prerequisite of`: questo nodo sblocca l'esecuzione di un altro
Usa una dependency quando vuoi garantire l'ordine corretto tra due step, ma senza passare un valore in input.
## Creare un flow manualmente
### 1. Aprire l'editor
Vai nel tab `Editor`. Se non hai un flow aperto puoi:
- crearne uno nuovo
- usare l'assistente per generarlo
### 2. Aggiungere blocchi o container
Dal pannello sinistro:
- cerca il tipo di blocco o container
- trascinalo nel canvas
Se il catalogo non e ancora pronto, vedrai un loader al posto del messaggio vuoto.
### 3. Spostare e organizzare i nodi
Puoi:
- trascinare i nodi nel canvas
- selezionare e spostare nodi
- clonare un nodo con l'icona di clone
- eliminare un nodo con l'icona di delete
Quando elimini un nodo, vengono rimosse anche:
- le connessioni dati collegate
- le dependency edges collegate
### 4. Collegare i nodi
Per passare dati:
- collega un output a un input
Per imporre solo ordine di esecuzione:
- collega `Prerequisite of` del nodo sorgente
- a `Depends on` del nodo destinazione
Le connessioni di dependency sono visualizzate in modo diverso dalle connessioni dati, con linea piu fine e tratteggiata.
### 5. Modificare il nome dei nodi
Per i blocchi e i container:
- clicca l'icona matita accanto al nome
- modifica il nome
- conferma con `Save`
### 6. Configurare i parametri
Cliccando su un nodo puoi vedere i suoi parametri.
Per i campi editabili:
- usa il pulsante matita sui parametri
- modifica il valore nel dialog
- salva
Per i testi lunghi:
- se il campo e lungo viene troncato
- puoi aprirlo per intero con l'icona occhio
Nel caso dei subflow in sola lettura, i campi lunghi mantengono comunque l'icona occhio per una lettura completa in readonly.
## Lavorare con i container
### Inserire un subflow
Un container puo ricevere un subflow in piu modi:
- importando un flow
- trascinando dentro una selezione di nodi
Quando un subflow viene sostituito:
- la configurazione strutturale del container viene ricostruita
- i vecchi parametri del subflow precedente non vengono mantenuti
### Importare un flow nel container
Se il tipo di container lo supporta:
- clicca `Import flow`
- scegli il flow disponibile
- conferma
### Visualizzare il subflow
Se il container contiene un subflow:
- compare il pulsante `View Flow`
- si apre una finestra di anteprima in sola lettura
Da questa vista puoi:
- esplorare il subflow
- aprire i parametri lunghi in readonly con l'icona occhio
## Parametri obbligatori e validazione locale
Se un nodo ha parametri mancanti, compare un indicatore di warning.
Per i blocchi il warning mostra i campi obbligatori mancanti.
Per i container:
- se manca il `subFlow`, viene mostrato solo `Subflow`
- non vengono mostrati come mancanti i campi interni del `FlowData` come `Blocks`, `Connections` o `Dependencies`
Inoltre i campi tecnici di tipo non vengono mostrati come parametri utente. Questo include campi come:
- `type`
- `typeName`
- `containerType`
- `configurationType`
- `configurationClass`
## Uso dell'assistente
L'assistente si trova nel pannello destro dell'editor.
### Modalita create e refine
L'assistente cambia comportamento in base allo stato del flow aperto:
- se non c'e nessun flow aperto, oppure il flow aperto e vuoto, l'assistente lavora in modalita `Create`
- se il flow aperto contiene gia nodi o connessioni, l'assistente lavora in modalita `Refine`
Questo significa che un flow vuoto non blocca la creazione assistita.
### Cosa puoi chiedere all'assistente
Esempi tipici:
- creare un flow da zero
- modificare un flow esistente
- spiegare un flow
- aiutare a correggere problemi di validazione
### Risultato dell'assistente
Quando l'assistente restituisce un draft:
- il flow viene caricato nell'editor
- puoi continuare a modificarlo manualmente
- puoi salvarlo come un flow normale
## Salvataggio del flow
Quando lavori nell'editor, il flow puo essere modificato ma non ancora salvato.
### Salvataggio
Usa il pulsante `Save` nella toolbar del flow.
Il save:
- aggiorna il flow sul backend
- ricalcola la validazione quando necessario
### Rinominare il flow
Il titolo del flow usa azioni esplicite:
- `Save`
- `Cancel`
Non viene piu salvato automaticamente al blur del campo.
## Pannello errori di validazione
Nel pannello destro c'e un'icona dedicata agli errori del flow.
### Quando compare
L'icona:
- e sempre visibile nella rail destra
- e disabilitata se non ci sono errori
- si attiva quando il flow ha errori di validazione
### Come vengono caricati gli errori
Gli errori vengono richiesti dal backend:
- dopo il save, se il flow non e `EXECUTABLE`
- anche all'apertura di un flow gia `DRAFT`
### Cosa mostra il pannello errori
Per ogni errore vengono mostrati:
- codice
- messaggio leggibile
I metadati troppo rumorosi come `entity`, `field` e `id` non vengono mostrati nella card.
### Evidenziazione dei nodi
Se l'errore include nodi correlati:
- questi nodi vengono evidenziati nel canvas
### Validazione stale
Se fai una modifica strutturale senza salvare ancora, il pannello errori mostra un avviso:
- `Validation will be recomputed after save.`
Gli spostamenti puramente grafici dei nodi non contano come modifica strutturale.
## Published e Finalized
Se sei il proprietario del flow puoi vedere due controlli nella toolbar:
- `Published`
- `Finalized`
### Published
Controlla la visibilita del flow.
Puoi:
- pubblicare
- depubblicare
### Finalized
Segna il flow come definitivo e non piu modificabile.
Una volta finalizzato:
- il contenuto del flow diventa read-only
- il flow non puo essere un-finalized
- il flow non puo essere cancellato
- il publish/depublish resta comunque disponibile
## Eseguire un flow
Quando un flow e valido ed eseguibile, puoi usare `Execute`.
### Cosa succede al click su Execute
L'app:
- apre subito il tab `Tasks`
- crea l'esecuzione in background
- mostra un loader finche la nuova execution non e pronta
Questo evita il ritardo percepito prima del cambio tab.
## Lavorare nel tab Tasks
Nel tab `Tasks` hai una lista di esecuzioni sulla sinistra e il dettaglio a destra.
### Lista delle esecuzioni
Ogni elemento mostra:
- nome
- stato
- data/ora
- eventuale badge `Simulated`
### Dettaglio di una esecuzione
Nel dettaglio puoi:
- vedere il grafo in sola lettura
- controllare input richiesti
- leggere output e log
- eseguire azioni come start, simulate, cancel o resume se disponibili
## Input delle esecuzioni
Se un execution step richiede input manuali, li trovi nel pannello dedicato.
### Modalita di salvataggio degli input
Gli input non vengono piu inviati automaticamente on blur.
Ora il comportamento e:
- modifichi il valore
- il draft resta locale
- premi `Save` per inviarlo
Questo vale anche per campi multipli.
## Blocchi Human Interaction
I blocchi di interazione umana possono richiedere conferma o inserimento manuale.
### Dialog di interazione
Quando il nodo lo richiede, si apre un dialog dedicato.
Nel caso non-chat puoi:
- confermare l'input corrente
- modificare la risposta
- inviare con `Send Output`
Nel caso chat puoi:
- continuare la conversazione
- inviare una risposta finale
### Invio reale al backend
I pulsanti di invio effettuano una chiamata reale al backend. In particolare:
- `Send Output`
- `Confirm Input`
- invio messaggi chat
- invio risposta finale
passano attraverso l'endpoint di interaction dell'esecuzione.
## Visualizzazione dei task node
Nel viewer di esecuzione:
- i container mostrano il pulsante `View Subflow`
- le dependency ports vengono mostrate solo se effettivamente connesse
- i testi lunghi possono essere aperti in readonly
Se un prompt o un parametro contiene placeholder:
- quando il valore runtime e disponibile, il preview puo mostrarlo risolto
- se il valore non e ancora pronto, il placeholder originale resta visibile
## Output delle esecuzioni
Gli output sono raggruppati per nodo.
### Struttura della vista output
Per ogni nodo trovi:
- titolo del nodo
- elenco dei singoli output
Se un output supera la lunghezza prevista:
- viene troncato
- puoi aprirlo interamente con l'icona occhio
### Output array
Se la response e un array:
- non viene mostrata come blob unico
- viene espansa in entry separate, come `Item 1`, `Item 2`, eccetera
## Logs delle esecuzioni
Nel tab dei log:
- il contenuto scorre automaticamente in basso durante il refresh
- viene mostrato il testo leggibile
- il blocco JSON raw dei dettagli non viene piu visualizzato
## Caricamento di blocchi, container e schemi
### Catalogo blocchi e container
Nel pannello sinistro:
- se il catalogo e in caricamento, compare un loader
- non viene mostrato `No blocks found` o `No containers found` durante il fetch iniziale
### Caricamento schema del nodo
Se clicchi un nodo e lo schema non e ancora disponibile:
- il nodo mostra un loader (`Loading block...` o `Loading container...`)
- il click puo forzare un retry del caricamento
Questo e utile quando i type descriptor arrivano in cache solo dopo il mount del nodo.
## Connessioni e selezione
Le connessioni, sia dati sia dependency, possono essere selezionate.
Quando una connessione e selezionata:
- viene evidenziata
- compare l'icona `x` per eliminarla
- puoi anche usare `Delete` o `Backspace`
Cliccando nel vuoto del canvas:
- la connessione viene deselezionata
## Suggerimenti pratici
- salva spesso dopo modifiche strutturali
- controlla il pannello errori prima di eseguire
- usa `Connections` per passare dati e `Dependencies` solo per imporre ordine
- usa i container per isolare parti riutilizzabili del flow
- se un nodo sembra incompleto, controlla i parametri mancanti prima di eseguire
- se un testo e troncato, usa l'icona occhio invece di allargare il nodo
## Problemi comuni
### Vedo un loader invece dei blocchi nella sidebar
Il catalogo blocchi o container e ancora in caricamento. Attendi che il backend restituisca i type descriptor.
### Un nodo sembra vuoto quando lo apro
Lo schema del tipo potrebbe non essere ancora disponibile. Cliccando il nodo l'app puo ritentare automaticamente il caricamento.
### Un container risulta incompleto
Verifica che abbia un `Subflow`. Un container senza subflow viene considerato mancante.
### Il flow resta DRAFT dopo il save
Apri il pannello errori di validazione. Il flow puo essere stato salvato ma non essere ancora eseguibile.
### Non vedo una risposta intera nei task
Se il valore e lungo o e stato troncato, usa l'icona occhio per aprire il contenuto completo in readonly.
## Conclusione
HumAIn Flow permette di lavorare sia in modo visuale sia assistito. Il percorso tipico consigliato e:
1. creare o aprire un flow
2. costruirlo manualmente o con l'assistente
3. salvare
4. correggere eventuali errori di validazione
5. eseguire il flow dal tab `Tasks`
6. monitorare input, output e log fino al completamento
Se vuoi distribuire questa guida agli utenti finali, puoi condividerla direttamente come documento markdown oppure convertirla in PDF o pagina documentazione interna.

19
package-lock.json generated
View File

@ -3353,6 +3353,17 @@
"license": "MIT",
"optional": true
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
@ -4059,7 +4070,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
@ -10087,6 +10097,13 @@
"peerDependencies": {
"zod": "^3.25 || ^4"
}
},
"node_modules/zone.js": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.1.tgz",
"integrity": "sha512-dpvY17vxYIW3+bNrP0ClUlaiY0CiIRK3tnoLaGoQsQcY9/I/NpzIWQ7tQNhbV7LacQMpCII6wVzuL3tuWOyfuA==",
"license": "MIT",
"peer": true
}
}
}

View File

@ -1,8 +1,8 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideHttpClient, withInterceptors, withXsrfConfiguration } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { authTokenInterceptor } from './interceptors/auth-token.interceptor';
import { authTokenInterceptor, withCredentialsInterceptor } from './interceptors/auth-token.interceptor';
import { routes } from './app.routes';
@ -10,7 +10,10 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideAnimations(),
provideHttpClient(withInterceptors([authTokenInterceptor])),
provideHttpClient(
withInterceptors([withCredentialsInterceptor, authTokenInterceptor]),
withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' })
),
provideRouter(routes)
]
};

View File

@ -1,4 +1,4 @@
import { Component, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog';
import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog';
@ -9,7 +9,8 @@ import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialo
selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogHostComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent],
templateUrl: './app.html',
styleUrl: './app.css'
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
protected readonly title = signal('HumAInFlow');

View File

@ -1,53 +1,31 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { Authorization } from '@services/authorization/authorization';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
let lastSessionExpiredNotificationAt = 0;
let lastServiceErrorNotificationAt = 0;
export const withCredentialsInterceptor: HttpInterceptorFn = (req, next) => {
return next(req.clone({ withCredentials: true }));
};
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const token = getToken();
const router = inject(Router);
const requestPath = req.url.split('?')[0];
const isChangePasswordEndpoint = requestPath.endsWith('/auth/change-password');
const isAuthEndpoint =
requestPath.endsWith('/auth/login') ||
requestPath.endsWith('/auth/register');
const hasToken = !!token;
if (token && !isAuthEndpoint && isTokenExpired(token)) {
clearAuthStorage();
notifySessionExpired();
redirectToLogin();
return throwError(() =>
new HttpErrorResponse({
status: 401,
statusText: 'Session expired',
error: { message: 'Session expired' }
})
);
}
let requestToSend = req;
if (token && !isAuthEndpoint && !req.headers.has('Authorization')) {
requestToSend = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next(requestToSend).pipe(
return next(req).pipe(
catchError((error: unknown) => {
if (
hasToken &&
!isAuthEndpoint &&
!isChangePasswordEndpoint &&
error instanceof HttpErrorResponse &&
error.status === 401
) {
clearAuthStorage();
notifySessionExpired();
redirectToLogin();
redirectToLogin(router);
}
if (
@ -62,59 +40,21 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
);
};
function getToken(): string | null {
const directToken = localStorage.getItem(Authorization.TOKEN_STORAGE_KEY);
if (directToken) return directToken;
const rawUser = localStorage.getItem(Authorization.USER_STORAGE_KEY);
if (!rawUser) return null;
try {
const parsed = JSON.parse(rawUser) as { token?: unknown };
return typeof parsed?.token === 'string' && parsed.token.length > 0 ? parsed.token : null;
} catch {
return null;
}
}
function isTokenExpired(token: string): boolean {
try {
const payloadSegment = token.split('.')[1];
if (!payloadSegment) return false;
const normalized = payloadSegment.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
const decoded = atob(padded);
const payload = JSON.parse(decoded) as { exp?: unknown };
if (typeof payload.exp !== 'number') return false;
const nowSeconds = Math.floor(Date.now() / 1000);
return payload.exp <= nowSeconds;
} catch {
return false;
}
}
function clearAuthStorage() {
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
}
function notifySessionExpired() {
const now = Date.now();
if (now - lastSessionExpiredNotificationAt < 1200) return;
lastSessionExpiredNotificationAt = now;
window.alert('Sessione scaduta. Effettua di nuovo il login.');
console.warn('Session expired. Redirecting to login.');
}
function notifyServiceContactError() {
const now = Date.now();
if (now - lastServiceErrorNotificationAt < 1200) return;
lastServiceErrorNotificationAt = now;
window.alert('Error contacting service, please retry later.');
console.error('Error contacting service, please retry later.');
}
function redirectToLogin() {
if (window.location.pathname === '/login') return;
window.location.assign('/login');
function redirectToLogin(router: Router) {
if (router.url === '/login') return;
void router.navigateByUrl('/login');
}

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@ -7,7 +7,8 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
selector: 'app-admin-layout',
imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet, MatIconModule],
templateUrl: './admin-layout.html',
styleUrl: './admin-layout.css'
styleUrl: './admin-layout.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminLayout {
}

View File

@ -1,4 +1,4 @@
import { afterNextRender, Component, inject, signal } from '@angular/core';
import { afterNextRender, ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
@ -16,6 +16,7 @@ import { ChangePasswordDialogComponent } from '@shared/change-password-dialog/ch
imports: [RouterOutlet, RouterLink, RouterLinkActive, ChangePasswordDialogComponent, MatButtonModule, MatIconModule, MatMenuModule, MatTabsModule, MatToolbarModule],
templateUrl: './app-layout.html',
styleUrl: './app-layout.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppLayout {
@ -51,8 +52,9 @@ export class AppLayout {
}
logout() {
this.authService.logout();
this.router.navigate(['/login']);
this.authService.logout().subscribe(() => {
this.router.navigate(['/login']);
});
}
openChangePasswordDialog() {

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener, ViewChild, computed, effect, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, HostListener, ViewChild, computed, effect, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@ -30,6 +30,7 @@ type TourStep = {
imports: [CommonModule, EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, FlowValidationPanel, MatButtonModule, MatCardModule, MatIconModule],
templateUrl: './flow-editor.html',
styleUrl: './flow-editor.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowEditor {
private static readonly SIDEBAR_OPEN_DELAY_MS = 320;
@ -289,7 +290,8 @@ export class FlowEditor {
targetName: targetInput
}
],
dependencies: []
dependencies: [],
globalInputs: []
};
}

View File

@ -1,4 +1,4 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { normalizeExecutionStatus, TaskExecution } from '@models/task-execution';
import { ActivatedRoute, Router } from '@angular/router';
@ -18,6 +18,7 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions
imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, MatCardModule],
templateUrl: './tasks-executor.html',
styleUrl: './tasks-executor.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TasksExecutor {
private taskExecutionsService = inject(TaskExecutionsService);

View File

@ -23,6 +23,13 @@ export type FlowData = {
containers: FlowContainer[];
connections: FlowBlockConnection[];
dependencies: FlowNodeDependency[];
globalInputs?: FlowGlobalInput[];
};
export type FlowGlobalInput = {
name: string;
type: string;
multiple: boolean;
};
export type FlowSubflowValidationError = {

View File

@ -30,6 +30,7 @@ export type TaskExecution = {
requiredAuthorizations?: Record<string, TaskExecutionAuthorizationRequirement>;
providedAuthorizations?: Record<string, unknown>;
missingAuthorizationKeys?: string[];
missingGlobalInputKeys?: string[];
};
export type TaskExecutionAuthorizationRequirement = {
@ -42,6 +43,8 @@ export type TaskExecutionAuthorizationRequirement = {
export type TaskExecutionContext = {
inputs: Record<string, unknown>;
globalInputs?: Record<string, unknown>;
globalInputDescriptors?: Record<string, TaskExecutionGlobalInputDescriptor>;
result: Record<string, unknown>;
partialResult?: Record<string, unknown>;
startTime?: number | null;
@ -53,6 +56,15 @@ export type TaskExecutionContext = {
waitingSteps: string[];
};
export type TaskExecutionGlobalInputDescriptor = {
name: string;
kind: string;
value: unknown;
description?: string | null;
cleanupPolicy?: string | null;
multiple?: boolean;
};
export type TaskExecutionStep = {
node?: FlowNode;
id: string;

View File

@ -4,7 +4,6 @@ export type User = {
username: string;
email: string | null;
role: UserRole;
token?: string;
};
export type UserRegistration = {

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@ -35,7 +35,8 @@ function hasValidPasswordComplexity(value: string): boolean {
MatSelectModule
],
templateUrl: './admin-create-user.html',
styleUrl: './admin-create-user.css'
styleUrl: './admin-create-user.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminCreateUserPage extends FormUtility {
private authorization = inject(Authorization);

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
@ -21,7 +21,8 @@ import { Authorization } from '@services/authorization/authorization';
MatInputModule
],
templateUrl: './admin-stats.html',
styleUrl: './admin-stats.css'
styleUrl: './admin-stats.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminStatsPage {
private authorization = inject(Authorization);

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
@ -25,7 +25,8 @@ import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-
AdminResetPasswordDialogComponent
],
templateUrl: './admin-users-list.html',
styleUrl: './admin-users-list.css'
styleUrl: './admin-users-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminUsersListPage {
private authorization = inject(Authorization);

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@ -41,7 +41,8 @@ function hasValidPasswordComplexity(value: string): boolean {
AdminResetPasswordDialogComponent
],
templateUrl: './admin-users.html',
styleUrl: './admin-users.css'
styleUrl: './admin-users.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminUsersPage extends FormUtility {
private authorization = inject(Authorization);

View File

@ -1,4 +1,4 @@
import { Component, effect, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
@ -16,6 +16,7 @@ import { Field, form, required } from '@angular/forms/signals';
imports: [FormsModule, RouterLink, Field, MatButtonModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule],
templateUrl: './login.html',
styleUrls: ['./login.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Login extends FormUtility {

View File

@ -1,4 +1,4 @@
import { AfterViewInit, Component, effect, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
import { AfterViewInit, ChangeDetectionStrategy, Component, effect, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
@ -39,6 +39,7 @@ let turnstileScriptPromise: Promise<void> | null = null;
imports: [FormsModule, RouterLink, Field, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule],
templateUrl: './signup.html',
styleUrl: './signup.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Signup extends FormUtility implements AfterViewInit, OnDestroy {

View File

@ -1,4 +1,4 @@
import { Component, computed, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } 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';
@ -18,7 +18,8 @@ type OpenedId = 'flows' | 'blocks' | 'containers';
imports: [GroupHolder, FlowsList, BlocksList, ContainersList, CommonModule],
templateUrl: './editor-sidebar.html',
styleUrl: './editor-sidebar.css',
providers:[ListState]
providers:[ListState],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditorSidebar {

View File

@ -35,4 +35,6 @@ export abstract class AuthorizationCallServiceBase {
abstract getUserStatistics(username: string): Observable<UserStatistics>;
abstract logout(): Observable<void>;
}

View File

@ -1,4 +1,4 @@
import { Observable } from "rxjs";
import { Observable, of } from "rxjs";
import { AuthorizationCallServiceBase } from "./authorization-call.base";
import {
AdminChangeRoleRequest,
@ -35,8 +35,7 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
observer.next({
username: user.username,
email: user.email,
role: user.role,
token: 'fake-jwt-token'
role: user.role
});
observer.complete();
});
@ -220,4 +219,8 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
lastExecutionAt: 1774520966139
};
}
logout(): Observable<void> {
return of(undefined);
}
}

View File

@ -11,7 +11,7 @@ import {
UserStatistics,
UserRole
} from "@models/user";
import { catchError, map, Observable, throwError } from "rxjs";
import { catchError, map, Observable, of, throwError } from "rxjs";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
@ -29,13 +29,11 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
(payload["user"] as Record<string, unknown> | undefined)
?? (payload["profile"] as Record<string, unknown> | undefined)
?? payload;
const token = this.extractToken(payload, userSource);
return {
username: String(userSource["username"] ?? username),
email: typeof userSource["email"] === "string" ? String(userSource["email"]) : null,
role: this.normalizeRole(userSource["role"]),
token: typeof token === "string" && token.length > 0 ? token : undefined
role: this.normalizeRole(userSource["role"])
} satisfies User;
}),
catchError((error: unknown) => {
@ -198,20 +196,6 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
return null;
}
private extractToken(...sources: Array<Record<string, unknown> | undefined>): unknown {
const tokenKeys = ['token', 'accessToken', 'access_token', 'jwt', 'id_token'];
for (const source of sources) {
if (!source) continue;
for (const key of tokenKeys) {
const value = source[key];
if (typeof value === 'string' && value.length > 0) {
return value;
}
}
}
return undefined;
}
private normalizeRole(value: unknown): UserRole {
return String(value ?? '').toUpperCase() === 'ADMIN' ? 'ADMIN' : 'USER';
}
@ -259,6 +243,15 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
};
}
override logout(): Observable<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/logout`, {})
.pipe(
map(() => undefined),
catchError(() => of(undefined))
);
}
private toHttpError(error: unknown, fallbackByStatus: Record<number, string>): Observable<never> {
if (error instanceof HttpErrorResponse) {
const message = this.extractHttpErrorMessage(error)

View File

@ -11,14 +11,13 @@ import {
} from '@models/user';
import { AuthorizationCallServiceBase } from './authorization-call.base';
import { environment } from '@environment';
import { take, tap } from 'rxjs';
import { Observable, take, tap } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class Authorization {
static readonly USER_STORAGE_KEY = 'loggedInUser';
static readonly TOKEN_STORAGE_KEY = 'authToken';
private user = signal<User | null>(null);
@ -31,15 +30,9 @@ export class Authorization {
take(1),
tap(res => {
const normalizedUser = this.normalizeUser(res);
if (normalizedUser) {
console.log('[auth] logged user role:', normalizedUser.role);
}
this.user.set(normalizedUser);
localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(normalizedUser));
if (res.token) {
localStorage.setItem(Authorization.TOKEN_STORAGE_KEY, res.token);
} else {
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
if (typeof localStorage !== 'undefined') {
localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(normalizedUser));
}
})
);
@ -105,22 +98,22 @@ export class Authorization {
);
}
logout() {
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
this.user.set(null);
}
token(): string | null {
return localStorage.getItem(Authorization.TOKEN_STORAGE_KEY);
logout(): Observable<void> {
return this.authCall.logout().pipe(
take(1),
tap(() => {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
}
this.user.set(null);
})
);
}
isLoggedIn(): boolean {
if (typeof localStorage === 'undefined') return false;
const storedUser = localStorage.getItem(Authorization.USER_STORAGE_KEY);
const normalizedUser = storedUser != null ? this.normalizeUser(JSON.parse(storedUser) as User) : null;
if (normalizedUser) {
console.log('[auth] restored user role:', normalizedUser.role);
}
this.user.set(normalizedUser);
return storedUser != null;
}
@ -135,8 +128,7 @@ export class Authorization {
return {
username: String(user.username ?? ''),
email: typeof user.email === 'string' ? user.email : null,
role: user.role === 'ADMIN' ? 'ADMIN' : 'USER',
token: user.token
role: user.role === 'ADMIN' ? 'ADMIN' : 'USER'
};
}

View File

@ -1,4 +1,4 @@
import { Flow, FlowBlock, FlowContainer, FlowData, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
import { Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
function parseDate(value: unknown, fallback: Date): Date {
if (typeof value !== 'string' || !value) return fallback;
@ -35,7 +35,8 @@ export function flowFromApi(raw: unknown): Flow {
blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[],
containers: normalizeNodes(data.containers, 'container') as FlowContainer[],
connections: Array.isArray(data.connections) ? data.connections : [],
dependencies: Array.isArray(data.dependencies) ? data.dependencies : []
dependencies: Array.isArray(data.dependencies) ? data.dependencies : [],
globalInputs: normalizeGlobalInputs(data.globalInputs)
}
};
}
@ -49,11 +50,24 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F
blocks: [],
containers: [],
connections: [],
dependencies: []
dependencies: [],
globalInputs: []
}
};
}
function normalizeGlobalInputs(raw: unknown): FlowGlobalInput[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
.map((item) => ({
name: String(item['name'] ?? '').trim(),
type: String(item['type'] ?? 'TEXT').toUpperCase() || 'TEXT',
multiple: Boolean(item['multiple'])
}));
}
function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container'): Array<FlowBlock | FlowContainer> {
if (!Array.isArray(raw)) return [];

View File

@ -33,7 +33,8 @@ export class FlowsCallService extends FlowsCallServiceBase {
blocks: [],
containers: [],
connections: [],
dependencies: []
dependencies: [],
globalInputs: []
},
status: 'DRAFT'
});

View File

@ -160,7 +160,8 @@ export class FlowsService {
blocks: [],
containers: [],
connections: [],
dependencies: []
dependencies: [],
globalInputs: []
},
status: 'DRAFT'
}).pipe(

View File

@ -35,6 +35,26 @@ export abstract class TaskExecutionsCallServiceBase {
inputName: string,
files: File[]
): Observable<TaskExecution>;
abstract prepareGlobalStringInput(
executionId: string,
inputName: string,
value: string
): Observable<TaskExecution>;
abstract prepareGlobalStringArrayInput(
executionId: string,
inputName: string,
values: string[]
): Observable<TaskExecution>;
abstract prepareGlobalFileInput(
executionId: string,
inputName: string,
file: File
): Observable<TaskExecution>;
abstract prepareGlobalFileArrayInput(
executionId: string,
inputName: string,
files: File[]
): Observable<TaskExecution>;
abstract submitInteractionText(
executionId: string,
nodeId: string,

View File

@ -610,6 +610,50 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(execution);
}
override prepareGlobalStringInput(
executionId: string,
inputName: string,
value: string
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.inputs[`global:${inputName}`] = value;
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
return of(execution);
}
override prepareGlobalStringArrayInput(
executionId: string,
inputName: string,
values: string[]
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.inputs[`global:${inputName}`] = values;
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
return of(execution);
}
override prepareGlobalFileInput(
executionId: string,
inputName: string,
file: File
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.inputs[`global:${inputName}`] = file?.name ?? '';
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
return of(execution);
}
override prepareGlobalFileArrayInput(
executionId: string,
inputName: string,
files: File[]
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.inputs[`global:${inputName}`] = files.map((file) => file?.name ?? '');
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
return of(execution);
}
override submitInteractionText(
executionId: string,
nodeId: string,

View File

@ -3,14 +3,16 @@ import { inject } from '@angular/core';
import { environment } from '@environment';
import { LLMDescriptor } from '@models/flow';
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
import { Observable } from 'rxjs';
import { map, Observable } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
private readonly http = inject(HttpClient);
override retrieveAllTaskExecutions(): Observable<TaskExecution[]> {
return this.http.get<TaskExecution[]>(`${environment.apiUrl}/executions`);
return this.http.get<unknown[]>(`${environment.apiUrl}/executions`).pipe(
map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecution(item)) : [])
);
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
@ -18,7 +20,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
}
override createTaskExecution(flowId: string): Observable<TaskExecution> {
return this.http.post<TaskExecution>(`${environment.apiUrl}/executions`, flowId);
return this.http.post<unknown>(`${environment.apiUrl}/executions`, flowId).pipe(
map((raw) => this.mapExecution(raw))
);
}
override deleteTaskExecution(executionId: string): Observable<void> {
@ -26,19 +30,27 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
}
override startTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null);
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null).pipe(
map((raw) => this.mapExecution(raw))
);
}
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator });
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator }).pipe(
map((raw) => this.mapExecution(raw))
);
}
override cancelTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null);
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null).pipe(
map((raw) => this.mapExecution(raw))
);
}
override resumeTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/resume`, null);
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/resume`, null).pipe(
map((raw) => this.mapExecution(raw))
);
}
override prepareStringInput(
@ -48,9 +60,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
value: string
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/text`;
return this.http.put<TaskExecution>(url, value, {
return this.http.put<unknown>(url, value, {
headers: { 'Content-Type': 'text/plain' }
});
}).pipe(map((raw) => this.mapExecution(raw)));
}
override prepareStringArrayInput(
@ -60,7 +72,7 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
values: string[]
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/texts`;
return this.http.put<TaskExecution>(url, values);
return this.http.put<unknown>(url, values).pipe(map((raw) => this.mapExecution(raw)));
}
override prepareFileInput(
@ -72,7 +84,7 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/file`;
const formData = new FormData();
formData.append('file', file);
return this.http.put<TaskExecution>(url, formData);
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
}
override prepareFileArrayInput(
@ -86,7 +98,54 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
for (const file of files) {
formData.append('files', file);
}
return this.http.put<TaskExecution>(url, formData);
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
}
override prepareGlobalStringInput(
executionId: string,
inputName: string,
value: string
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals/${encodeURIComponent(inputName)}`;
return this.http.put<unknown>(url, JSON.stringify(value), {
headers: { 'Content-Type': 'application/json' }
}).pipe(map((raw) => this.mapExecution(raw)));
}
override prepareGlobalStringArrayInput(
executionId: string,
inputName: string,
values: string[]
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals`;
return this.http.put<unknown>(url, {
[inputName]: values
}).pipe(map((raw) => this.mapExecution(raw)));
}
override prepareGlobalFileInput(
executionId: string,
inputName: string,
file: File
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals/${encodeURIComponent(inputName)}`;
const formData = new FormData();
formData.append('file', file);
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
}
override prepareGlobalFileArrayInput(
executionId: string,
inputName: string,
files: File[]
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals`;
const formData = new FormData();
formData.append('key', inputName);
for (const file of files) {
formData.append(inputName, file);
}
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
}
override submitInteractionText(
@ -96,9 +155,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
value: string
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/interaction/${encodeURIComponent(fieldName)}/text`;
return this.http.put<TaskExecution>(url, value, {
return this.http.put<unknown>(url, value, {
headers: { 'Content-Type': 'text/plain' }
});
}).pipe(map((raw) => this.mapExecution(raw)));
}
override provideAuthorization(
@ -107,6 +166,55 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
value: string
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/authorizations`;
return this.http.put<TaskExecution>(url, { key, value });
return this.http.put<unknown>(url, { key, value }).pipe(map((raw) => this.mapExecution(raw)));
}
private mapExecution(raw: unknown): TaskExecution {
const execution = (raw ?? {}) as TaskExecution & Record<string, any>;
const context = (execution.context ?? {}) as Record<string, any>;
const globalInputs = this.normalizeGlobalInputValues(
context['globalInputs']
?? execution['globalInputs']
);
const globalInputDescriptors = this.normalizeGlobalInputDescriptors(
context['globalInputDescriptors']
?? execution['globalInputDescriptors']
);
return {
...execution,
context: {
...(context as TaskExecution['context']),
globalInputs,
globalInputDescriptors
} as TaskExecution['context']
};
}
private normalizeGlobalInputValues(raw: unknown): Record<string, unknown> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
return { ...(raw as Record<string, unknown>) };
}
private normalizeGlobalInputDescriptors(raw: unknown): NonNullable<TaskExecution['context']['globalInputDescriptors']> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
return Object.entries(raw as Record<string, unknown>)
.reduce<NonNullable<TaskExecution['context']['globalInputDescriptors']>>((acc, [key, value]) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return acc;
const record = value as Record<string, unknown>;
const name = String(record['name'] ?? key).trim();
if (!name) return acc;
acc[key] = {
name,
kind: String(record['kind'] ?? record['type'] ?? 'TEXT').toUpperCase(),
value: record['value'] ?? null,
description: typeof record['description'] === 'string' ? record['description'] : null,
cleanupPolicy: typeof record['cleanupPolicy'] === 'string' ? record['cleanupPolicy'] : null,
multiple: Boolean(record['multiple'])
};
return acc;
}, {});
}
}

View File

@ -165,6 +165,46 @@ export class TaskExecutionsService {
);
}
prepareGlobalStringInput(executionId: string, inputName: string, value: string) {
return this.taskExecutionsCallService.prepareGlobalStringInput(executionId, inputName, value).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Prepare global string input failed', err);
return throwError(() => err);
})
);
}
prepareGlobalStringArrayInput(executionId: string, inputName: string, values: string[]) {
return this.taskExecutionsCallService.prepareGlobalStringArrayInput(executionId, inputName, values).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Prepare global string array input failed', err);
return throwError(() => err);
})
);
}
prepareGlobalFileInput(executionId: string, inputName: string, file: File) {
return this.taskExecutionsCallService.prepareGlobalFileInput(executionId, inputName, file).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Prepare global file input failed', err);
return throwError(() => err);
})
);
}
prepareGlobalFileArrayInput(executionId: string, inputName: string, files: File[]) {
return this.taskExecutionsCallService.prepareGlobalFileArrayInput(executionId, inputName, files).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Prepare global file array input failed', err);
return throwError(() => err);
})
);
}
submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
if (execution?.interactionSimulationEnabled === true) {

View File

@ -15,7 +15,7 @@
matInput
[type]="hidePassword() ? 'password' : 'text'"
[field]="passwordForm.newPassword">
<button type="button" mat-icon-button matSuffix (click)="hidePassword.set(!hidePassword())">
<button type="button" mat-icon-button matSuffix (click)="hidePassword.set(!hidePassword())" [attr.aria-label]="hidePassword() ? 'Show password' : 'Hide password'">
<mat-icon [fontIcon]="hidePassword() ? 'visibility' : 'visibility_off'"></mat-icon>
</button>
</mat-form-field>
@ -35,7 +35,7 @@
matInput
[type]="hideConfirmPassword() ? 'password' : 'text'"
[field]="passwordForm.confirmNewPassword">
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())">
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())" [attr.aria-label]="hideConfirmPassword() ? 'Show confirm password' : 'Hide confirm password'">
<mat-icon [fontIcon]="hideConfirmPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
</button>
@if (isInvalid(passwordForm.confirmNewPassword())) {

View File

@ -1,4 +1,4 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@ -19,7 +19,8 @@ function hasValidPasswordComplexity(value: string): boolean {
selector: 'app-admin-reset-password-dialog',
imports: [FormsModule, Field, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule],
templateUrl: './admin-reset-password-dialog.html',
styleUrl: './admin-reset-password-dialog.css'
styleUrl: './admin-reset-password-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminResetPasswordDialogComponent extends FormUtility {
readonly username = input.required<string>();

View File

@ -1,4 +1,4 @@
import { Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { BlockType } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
@ -17,6 +17,7 @@ import { BLOCK_TYPE_DRAG_MIME } from './block-drag';
imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './blocks-list.html',
styleUrl: './blocks-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BlocksList extends ListStateViewHolder<BlockType> {

View File

@ -15,7 +15,7 @@
matInput
[type]="hideOldPassword() ? 'password' : 'text'"
[field]="passwordForm.oldPassword">
<button type="button" mat-icon-button matSuffix (click)="hideOldPassword.set(!hideOldPassword())">
<button type="button" mat-icon-button matSuffix (click)="hideOldPassword.set(!hideOldPassword())" [attr.aria-label]="hideOldPassword() ? 'Show current password' : 'Hide current password'">
<mat-icon [fontIcon]="hideOldPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
</button>
@if (isInvalid(passwordForm.oldPassword())) {
@ -29,7 +29,7 @@
matInput
[type]="hideNewPassword() ? 'password' : 'text'"
[field]="passwordForm.newPassword">
<button type="button" mat-icon-button matSuffix (click)="hideNewPassword.set(!hideNewPassword())">
<button type="button" mat-icon-button matSuffix (click)="hideNewPassword.set(!hideNewPassword())" [attr.aria-label]="hideNewPassword() ? 'Show new password' : 'Hide new password'">
<mat-icon [fontIcon]="hideNewPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
</button>
</mat-form-field>
@ -49,7 +49,7 @@
matInput
[type]="hideConfirmPassword() ? 'password' : 'text'"
[field]="passwordForm.confirmNewPassword">
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())">
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())" [attr.aria-label]="hideConfirmPassword() ? 'Show confirm password' : 'Hide confirm password'">
<mat-icon [fontIcon]="hideConfirmPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
</button>
@if (isInvalid(passwordForm.confirmNewPassword())) {

View File

@ -1,4 +1,4 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@ -20,7 +20,8 @@ function hasValidPasswordComplexity(value: string): boolean {
selector: 'app-change-password-dialog',
imports: [FormsModule, Field, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule],
templateUrl: './change-password-dialog.html',
styleUrl: './change-password-dialog.css'
styleUrl: './change-password-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChangePasswordDialogComponent extends FormUtility {
readonly username = input.required<string>();

View File

@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
@ -7,6 +7,7 @@ import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
standalone: true,
imports: [MatButtonModule],
templateUrl: './confirm-dialog.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmDialogHostComponent {

View File

@ -1,4 +1,4 @@
import { Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { BlockType } from '@models/flow';
import { ContainersService } from '@services/containers/containers';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
@ -17,6 +17,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './containers-list.html',
styleUrl: './containers-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContainersList extends ListStateViewHolder<BlockType> {
searchTerm = model<string>('');

View File

@ -1,4 +1,4 @@
import { Component, inject, Input } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core';
import { ClassicPreset } from 'rete';
import { FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
@ -79,7 +79,8 @@ import { GraphSelectionService } from '@services/graph-selection/graph-selection
overflow: visible;
pointer-events: auto;
}
`]
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomConnectionComponent {
private readonly graphSelection = inject(GraphSelectionService);

View File

@ -1,7 +1,8 @@
import { Component, HostBinding, Input } from "@angular/core";
import { ChangeDetectionStrategy, Component, HostBinding, Input } from "@angular/core";
@Component({
template: ``,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomSocket {
@Input() data!: any;

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -24,7 +24,8 @@ import { finalize, take } from 'rxjs';
selector: 'app-flow-assistant',
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './flow-assistant.html',
styleUrl: './flow-assistant.css'
styleUrl: './flow-assistant.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowAssistant implements OnInit, OnDestroy {
private readonly assistant = inject(AssistantService);

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { FlowValidationError } from '@models/flow';
@ -10,7 +10,8 @@ import { EditorStateHolder } from '@stores/flow-editor';
standalone: true,
imports: [CommonModule, MatButtonModule, MatIconModule],
templateUrl: './flow-validation-panel.html',
styleUrl: './flow-validation-panel.css'
styleUrl: './flow-validation-panel.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowValidationPanel {
private readonly editorState = inject(EditorStateHolder);

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, input, model } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, input, model } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@ -15,6 +15,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatTooltipModule],
templateUrl: './flow-item.html',
styleUrl: './flow-item.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowItem {

View File

@ -1,4 +1,4 @@
import { Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { Flow, FlowVisibility } from '@models/flow';
import { FlowsService } from '@services/flows/flows';
import { FlowItem } from './flow-item/flow-item';
@ -10,7 +10,7 @@ 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 { OrderEvent, OrderField, Ordering } from "@shared/ordering/ordering";
import { OrderEvent, OrderField, Ordering, orderDirType } from "@shared/ordering/ordering";
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
@ -20,6 +20,7 @@ type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
imports: [FlowItem, FormsModule, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule],
templateUrl: './flows-list.html',
styleUrl: './flows-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowsList extends ListStateViewHolder<Flow> {
filter = signal<FlowFilter>('all');
@ -30,6 +31,9 @@ export class FlowsList extends ListStateViewHolder<Flow> {
{ field: 'createdAt', label: 'Creation Date' }
];
readonly orderBy = signal<string | null>('name');
readonly orderDir = signal<orderDirType>('asc');
searchTerm = model<string>('');
private flowsService = inject(FlowsService);
@ -55,6 +59,9 @@ export class FlowsList extends ListStateViewHolder<Flow> {
ngOnInit() {
const existingState = this.view;
this.orderBy.set(existingState.order.orderBy);
this.orderDir.set(existingState.order.orderDir);
if (existingState.list) {
this.flows = existingState.list;
this.loading.set(false);
@ -91,12 +98,13 @@ export class FlowsList extends ListStateViewHolder<Flow> {
orderedFlows = computed(() => {
const flows = [...this.filteredFlows()];
const { orderBy, orderDir } = this.view.order;
const orderBy = this.orderBy();
const orderDir = this.orderDir();
if (!orderBy) return flows;
return flows.sort((a, b) => {
const aValue = (a as any)[orderBy];
const bValue = (b as any)[orderBy];
const aValue = this.toComparableValue((a as any)[orderBy]);
const bValue = this.toComparableValue((b as any)[orderBy]);
if (aValue == null && bValue == null) return 0;
if (aValue == null) return orderDir === 'asc' ? -1 : 1;
if (bValue == null) return orderDir === 'asc' ? 1 : -1;
@ -109,7 +117,17 @@ export class FlowsList extends ListStateViewHolder<Flow> {
onOrderChanged(event: OrderEvent) {
const { orderBy, orderDir } = event;
this.orderBy.set(orderBy);
this.orderDir.set(orderDir);
this.view.order = { orderBy, orderDir };
}
private toComparableValue(value: unknown): string | number | null {
if (value == null) return null;
if (value instanceof Date) return value.getTime();
if (typeof value === 'string') return value.toLowerCase();
if (typeof value === 'number') return value;
return String(value).toLowerCase();
}
}

View File

@ -1,10 +1,11 @@
import { Component, Input } from '@angular/core';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-group-holder',
imports: [],
templateUrl: './group-holder.html',
styleUrl: './group-holder.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GroupHolder {
@Input() title = '';

View File

@ -1,4 +1,4 @@
import { Component, computed, effect, ElementRef, inject, signal, viewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -12,7 +12,8 @@ import {
selector: 'app-human-interaction-dialog-host',
standalone: true,
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './human-interaction-dialog.html'
templateUrl: './human-interaction-dialog.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HumanInteractionDialogHostComponent {
private dialog = inject(HumanInteractionDialogService);

View File

@ -1,4 +1,4 @@
import { Component, effect, ElementRef, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, ElementRef, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@ -17,7 +17,8 @@ import {
selector: 'app-node-settings-dialog-host',
standalone: true,
imports: [FormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, MatTooltipModule],
templateUrl: './node-settings-dialog.html'
templateUrl: './node-settings-dialog.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NodeSettingsDialogHostComponent {
private dialog = inject(NodeSettingsDialogService);

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, HostListener, Input, inject } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, HostListener, Input, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
@ -68,7 +68,8 @@ type StructuredRetrieverConfig = {
styleUrl: './container-node.css',
host: {
'data-testid': 'node'
}
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContainerNodeComponent {
private editorState = inject(EditorStateHolder);
@ -1063,7 +1064,8 @@ export class ContainerNodeComponent {
: container
),
connections: flow.data.connections,
dependencies: flow.data.dependencies ?? []
dependencies: flow.data.dependencies ?? [],
globalInputs: flow.data.globalInputs ?? []
};
this.editorState.updateData(nextFlow);

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, HostListener, inject, Input } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, HostListener, inject, Input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, normalizeFlowPortValueKinds } from '@models/flow';
@ -143,7 +143,8 @@ type RenderedSocketPort = {
styleUrl: './generic-node.css',
host: {
'data-testid': 'node'
}
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GenericNodeComponent {

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
@ -86,7 +86,8 @@ type FieldUiMeta = {
styleUrl: './task-step-node.css',
host: {
'data-testid': 'task-step-node'
}
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskStepNodeComponent {
private static readonly globalFieldSchemaCache = new Map<string, Map<string, Record<string, any> | null>>();

View File

@ -1,4 +1,4 @@
import { Component, effect, Input, input, model, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, Input, input, model, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
@ -15,6 +15,7 @@ export type OrderField = { field: string; label?: string };
imports: [FormsModule, MatFormFieldModule, MatIconModule, MatSelectModule],
templateUrl: './ordering.html',
styleUrl: './ordering.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Ordering {

View File

@ -1,4 +1,4 @@
import { Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, untracked, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, untracked, ViewChild } from '@angular/core';
import { BlockType, FlowData, FlowNode } from '@models/flow';
import { Drag } from 'rete-area-plugin';
import { BlocksService } from '@services/blocks/blocks';
@ -7,7 +7,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
import { EditorStateHolder } from '@stores/flow-editor';
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance } from '@utilities/rete-editor';
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance, setEditorGlobalInputs } from '@utilities/rete-editor';
import { firstValueFrom } from 'rxjs';
@Component({
@ -15,6 +15,7 @@ import { firstValueFrom } from 'rxjs';
imports: [],
templateUrl: './rete-editor.html',
styleUrl: './rete-editor.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReteEditor implements OnChanges, OnDestroy {
readonly flowData = input.required<FlowData>();
@ -75,6 +76,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
void this.reloadEditor();
return;
}
if (changes['flowData'] && this.rete) {
setEditorGlobalInputs(this.rete.editor, this.flowData().globalInputs ?? []);
}
if (this.readonly() && changes['flowData']) {
void this.syncReadonlyFlowData();
}
@ -301,6 +305,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
}
this.rete = rete;
setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []);
this.syncAreaDragMode();
const loadedFlowId = this.flowId();
const normalizedData = exportGraph(rete.editor);
@ -355,6 +360,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
}
const nextFlowData = this.flowData();
setEditorGlobalInputs(rete.editor, nextFlowData.globalInputs ?? []);
if (!this.canPatchReadonlyFlowData(rete, nextFlowData)) {
await this.reloadEditor();
return;
@ -422,7 +428,17 @@ export class ReteEditor implements OnChanges, OnDestroy {
.sort();
if (currentDependencies.length !== nextDependencies.length) return false;
return currentDependencies.every((dependency, index) => dependency === nextDependencies[index]);
if (!currentDependencies.every((dependency, index) => dependency === nextDependencies[index])) return false;
const currentGlobalInputs = [...(currentFlowData.globalInputs ?? [])]
.map((input) => `${input.name}:${String(input.type ?? '').toUpperCase()}:${input.multiple ? 'multi' : 'single'}`)
.sort();
const nextGlobalInputs = [...(nextFlowData.globalInputs ?? [])]
.map((input) => `${input.name}:${String(input.type ?? '').toUpperCase()}:${input.multiple ? 'multi' : 'single'}`)
.sort();
if (currentGlobalInputs.length !== nextGlobalInputs.length) return false;
return currentGlobalInputs.every((input, index) => input === nextGlobalInputs[index]);
}
private async patchReadonlyNodes(rete: ReteEditorInstance, nextFlowData: FlowData) {
@ -513,6 +529,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.syncNodePositionFromContext(rete, context);
if (this.flowState.currentFlow()?.id !== loadedFlowId) return;
setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []);
const updatedData = exportGraph(rete.editor);
this.flowState.updateData(updatedData, { structural: context?.type !== 'nodetranslated' });
this.flowChanged.emit(updatedData);

View File

@ -1,4 +1,4 @@
import { Component, computed, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { ReteEditor } from '@shared/rete-editor/rete-editor';
@ -8,7 +8,8 @@ import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-d
selector: 'app-subflow-preview-dialog-host',
imports: [CommonModule, MatButtonModule, ReteEditor],
templateUrl: './subflow-preview-dialog.html',
styleUrl: './subflow-preview-dialog.css'
styleUrl: './subflow-preview-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SubflowPreviewDialogHostComponent {
private dialog = inject(SubflowPreviewDialogService);

View File

@ -70,10 +70,70 @@
@if (!editableInputs().length && !authorizationRequirements().length) {
<div class="text-xs text-slate-500">No manual inputs required.</div>
} @else {
@if (editableInputs().length) {
@if (globalExecutionInputs().length) {
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Globals</div>
<div class="mt-1 text-[11px] text-slate-500">Provide the shared flow-level inputs once before execution starts.</div>
</div>
@for (executionInput of globalExecutionInputs(); track executionInput.key) {
<div class="rounded-md border border-violet-200 bg-violet-50/60 p-2">
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-violet-200 bg-gradient-to-b from-violet-50 to-violet-100 px-3 py-2 text-center text-violet-700">
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-violet-600">{{ executionInput.subtitle }}</div>
</div>
<div class="mb-2 text-[10px] text-slate-500">Type: {{ inputTypeLabel(executionInput) }}</div>
@if (isFileInput(executionInput)) {
<input
type="file"
class="block w-full text-sm text-slate-700"
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
} @else {
@if (isMultipleInput(executionInput)) {
<div class="space-y-3">
@for (textValue of textValues(executionInput); track $index) {
<div class="flex items-start gap-2">
<mat-form-field appearance="outline" class="flex-1">
<mat-label>{{ executionInput.subtitle }} {{ $index + 1 }}</mat-label>
<textarea matInput rows="3" [readonly]="readOnly()" [ngModel]="textValue" (ngModelChange)="updateTextItem(executionInput, $index, $event)"></textarea>
</mat-form-field>
<button type="button" mat-stroked-button class="shrink-0" [disabled]="readOnly() || textValues(executionInput).length <= 1" (click)="removeTextItem(executionInput, $index)">Remove</button>
</div>
}
<button type="button" mat-stroked-button [disabled]="readOnly()" (click)="addTextItem(executionInput)">Add item</button>
<div class="flex justify-end">
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
</div>
</div>
} @else {
<div class="space-y-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>{{ executionInput.subtitle }}</mat-label>
<textarea matInput rows="4" [readonly]="readOnly()" [ngModel]="executionInput.value" (ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
</mat-form-field>
<div class="flex justify-end">
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
</div>
</div>
}
}
@if (isInputSaving(executionInput.key)) {
<div class="mt-2 text-[11px] text-blue-600">Saving...</div>
}
@if (inputSavingError(executionInput.key); as errorMessage) {
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
}
</div>
}
}
@if (nodeExecutionInputs().length) {
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Manual Inputs</div>
}
@for (executionInput of editableInputs(); track executionInput.key) {
@for (executionInput of nodeExecutionInputs(); track executionInput.key) {
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-blue-200 bg-gradient-to-b from-blue-50 to-blue-100 px-3 py-2 text-center text-blue-700">
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -8,7 +8,8 @@ import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
export type EditableExecutionInput = {
key: string;
nodeId: string;
scope: 'global' | 'node';
nodeId: string | null;
inputName: string;
title: string;
subtitle: string;
@ -20,7 +21,8 @@ export type EditableExecutionInput = {
@Component({
selector: 'app-task-execution-inputs-panel',
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './task-execution-inputs-panel.html'
templateUrl: './task-execution-inputs-panel.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskExecutionInputsPanelComponent {
readonly editableInputs = input<EditableExecutionInput[]>([]);
@ -37,6 +39,8 @@ export class TaskExecutionInputsPanelComponent {
readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>();
readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
readonly authorizationSubmit = output<TaskExecutionAuthorizationRequirement>();
readonly globalExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'global'));
readonly nodeExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'node'));
private readonly authorizationVisibility = new Map<string, boolean>();

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, ElementRef, inject, input, OnDestroy, signal, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, input, OnDestroy, signal, ViewChild } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
@ -62,6 +62,7 @@ type ExecutionLogEntryView = ExecutionEventLogEntry & {
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule],
templateUrl: './task-execution-viewer.html',
styleUrl: './task-execution-viewer.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly EVENTS_POLL_INTERVAL_MS = 5000;
@ -292,7 +293,13 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const connections = this.getExecutionConnections(steps);
const dependencies = this.getExecutionDependencies();
return { blocks, containers, connections, dependencies };
return {
blocks,
containers,
connections,
dependencies,
globalInputs: []
};
});
readonly formattedDuration = computed(() => {
@ -387,6 +394,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const status = String(execution.context.status ?? '').toUpperCase();
if (status !== 'CREATED' && status !== 'READY') return false;
const globalInputs = execution.context.globalInputs ?? {};
const globalInputDescriptors = execution.context.globalInputDescriptors ?? {};
for (const [descriptorKey, descriptor] of Object.entries(globalInputDescriptors)) {
const inputName = String(descriptor?.name ?? descriptorKey).trim();
if (!inputName) return false;
const value = Object.prototype.hasOwnProperty.call(globalInputs, inputName)
? globalInputs[inputName]
: descriptor?.value;
if (!this.isInputSet(value, Boolean(descriptor?.multiple))) return false;
}
for (const step of Object.values(execution.context.steps ?? {})) {
for (const input of step.inputs ?? []) {
if (input.registered) continue;
@ -429,6 +448,31 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const entries: EditableExecutionInput[] = [];
const contextInputs = execution.context.inputs ?? {};
const contextGlobalInputs = execution.context.globalInputs ?? {};
const globalInputDescriptors = execution.context.globalInputDescriptors ?? {};
for (const [descriptorKey, descriptor] of Object.entries(globalInputDescriptors)) {
const inputName = String(descriptor?.name ?? descriptorKey).trim();
if (!inputName) continue;
const key = `global:${inputName}`;
const rawValue = Object.prototype.hasOwnProperty.call(contextGlobalInputs, inputName)
? contextGlobalInputs[inputName]
: descriptor?.value;
const pendingValue = this.pendingTextInputs()[key];
entries.push({
key,
scope: 'global',
nodeId: null,
inputName,
title: 'Flow',
subtitle: inputName,
type: String(descriptor?.kind ?? 'TEXT').toUpperCase(),
multiple: Boolean(descriptor?.multiple),
value: pendingValue ?? this.normalizeEditableInputValue(rawValue, Boolean(descriptor?.multiple))
});
}
for (const step of Object.values(execution.context.steps ?? {})) {
for (const input of step.inputs ?? []) {
@ -445,6 +489,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
entries.push({
key,
scope: 'node',
nodeId: step.id,
inputName,
title: this.stepTitle(step),
@ -564,9 +609,17 @@ export class TaskExecutionViewerComponent implements OnDestroy {
if (!executionId || !files.length) return;
this.setInputSaving(input.key, true);
const request$ = input.multiple
? this.taskExecutionsService.prepareFileArrayInput(executionId, input.nodeId, input.inputName, files)
: this.taskExecutionsService.prepareFileInput(executionId, input.nodeId, input.inputName, files[0]);
const request$ = input.scope === 'global'
? (
input.multiple
? this.taskExecutionsService.prepareGlobalFileArrayInput(executionId, input.inputName, files)
: this.taskExecutionsService.prepareGlobalFileInput(executionId, input.inputName, files[0])
)
: (
input.multiple
? this.taskExecutionsService.prepareFileArrayInput(executionId, input.nodeId!, input.inputName, files)
: this.taskExecutionsService.prepareFileInput(executionId, input.nodeId!, input.inputName, files[0])
);
request$.subscribe({
next: () => this.clearInputSaving(input.key),
@ -662,20 +715,37 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const value = this.pendingTextInputs()[input.key] ?? this.normalizeEditableInputValue(input.value, input.multiple);
this.setInputSaving(input.key, true);
const request$ = input.multiple
? this.taskExecutionsService.prepareStringArrayInput(
executionId,
input.nodeId,
input.inputName,
(Array.isArray(value) ? value : [String(value)])
.map((item) => item.trim())
.filter((item) => item.length > 0)
const normalizedValues = (Array.isArray(value) ? value : [String(value)])
.map((item) => item.trim())
.filter((item) => item.length > 0);
const request$ = input.scope === 'global'
? (
input.multiple
? this.taskExecutionsService.prepareGlobalStringArrayInput(
executionId,
input.inputName,
normalizedValues
)
: this.taskExecutionsService.prepareGlobalStringInput(
executionId,
input.inputName,
String(Array.isArray(value) ? value[0] ?? '' : value)
)
)
: this.taskExecutionsService.prepareStringInput(
executionId,
input.nodeId,
input.inputName,
String(Array.isArray(value) ? value[0] ?? '' : value)
: (
input.multiple
? this.taskExecutionsService.prepareStringArrayInput(
executionId,
input.nodeId!,
input.inputName,
normalizedValues
)
: this.taskExecutionsService.prepareStringInput(
executionId,
input.nodeId!,
input.inputName,
String(Array.isArray(value) ? value[0] ?? '' : value)
)
);
request$.subscribe({

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, input, model, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, model, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
@ -30,6 +30,7 @@ export type TaskExecutionListItem = {
imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule],
templateUrl: './tasks-executions-list.html',
styleUrl: './tasks-executions-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TasksExecutionsListComponent {
readonly executions = input<TaskExecutionListItem[]>([]);

View File

@ -171,6 +171,163 @@
color: #475569;
}
.title-toolbar-global-inputs {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px 14px;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
}
.title-toolbar-global-inputs-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.title-toolbar-global-inputs-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.title-toolbar-global-inputs-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.title-toolbar-global-inputs-toggle .mat-icon {
color: #475569;
}
.title-toolbar-global-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
}
.title-toolbar-global-inputs-help,
.title-toolbar-global-syntax,
.title-toolbar-global-inputs-empty {
font-size: 12px;
color: #64748b;
}
.title-toolbar-global-input-row {
display: grid;
grid-template-columns: minmax(180px, 1fr) 140px 120px auto;
gap: 10px;
align-items: start;
}
.title-toolbar-global-input-row .mat-mdc-form-field {
margin-bottom: -1.25em;
}
.title-toolbar-global-select,
.title-toolbar-global-checkbox {
display: flex;
gap: 8px;
align-items: center;
min-height: 56px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #fff;
font-size: 12px;
color: #334155;
}
.title-toolbar-global-select {
justify-content: space-between;
}
.title-toolbar-global-select select {
width: 100%;
border: 0;
background: transparent;
color: #0f172a;
font: inherit;
outline: none;
}
.title-toolbar-global-checkbox input {
margin: 0;
}
.title-toolbar-global-delete {
margin-top: 8px;
}
.title-toolbar-global-error {
font-size: 12px;
color: #dc2626;
}
.title-toolbar-global-modal-backdrop {
position: fixed;
inset: 0;
z-index: 2100;
background: rgba(15, 23, 42, 0.38);
}
.title-toolbar-global-modal {
position: fixed;
top: 50%;
left: 50%;
z-index: 2101;
width: min(92vw, 420px);
padding: 18px;
border-radius: 16px;
background: #fff;
box-shadow: 0 22px 60px rgba(15, 23, 42, 0.28);
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
gap: 14px;
}
.title-toolbar-global-modal-title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.title-toolbar-global-modal-field {
margin-bottom: -1.25em;
}
.title-toolbar-global-modal-select,
.title-toolbar-global-modal-checkbox {
min-height: 48px;
}
.title-toolbar-global-modal-help {
font-size: 12px;
color: #64748b;
}
.title-toolbar-global-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.title-toolbar-meta-label {
color: #94a3b8;
}
@ -201,4 +358,10 @@
.title-toolbar-flags {
flex-wrap: wrap;
}
.title-toolbar-global-inputs-header,
.title-toolbar-global-input-row {
grid-template-columns: 1fr;
display: grid;
}
}

View File

@ -101,6 +101,132 @@
<span class="title-toolbar-description"><span class="title-toolbar-meta-label">Description:</span> {{ flow()!.description }}</span>
}
</div>
<div class="title-toolbar-global-inputs">
<div class="title-toolbar-global-inputs-header">
<div>
<button type="button" class="title-toolbar-global-inputs-toggle" (click)="toggleGlobalInputs()">
<mat-icon [fontIcon]="globalInputsOpen() ? 'expand_less' : 'expand_more'"></mat-icon>
<span class="title-toolbar-global-inputs-title">Global Inputs</span>
<span class="title-toolbar-global-count">{{ globalInputs().length }}</span>
</button>
<div class="title-toolbar-global-inputs-help">
Use shared flow-level inputs for values reused by multiple nodes. Reference them as template <code>{{ globalTemplateReference('name') }}</code> or SpEL <code>{{ globalSpelReference('name') }}</code>.
</div>
</div>
@if (!readOnly()) {
<button type="button" mat-icon-button matTooltip="Add global input" (click)="addGlobalInput()">
<mat-icon fontIcon="add"></mat-icon>
</button>
}
</div>
@if (globalInputsOpen()) {
@if (!globalInputs().length) {
<div class="title-toolbar-global-inputs-empty">No global inputs defined.</div>
} @else {
@for (globalInput of globalInputs(); track $index) {
<div class="title-toolbar-global-input-row">
<mat-form-field appearance="outline">
<mat-label>Name</mat-label>
<input
matInput
[readonly]="readOnly()"
[ngModel]="globalInput.name"
(ngModelChange)="updateGlobalInput($index, { name: $event })"
placeholder="sharedTopic" />
</mat-form-field>
<label class="title-toolbar-global-select">
<span>Type</span>
<select
[disabled]="readOnly()"
[ngModel]="globalInput.type"
(ngModelChange)="updateGlobalInput($index, { type: $event })">
<option value="TEXT">TEXT</option>
<option value="FILE">FILE</option>
</select>
</label>
<label class="title-toolbar-global-checkbox">
<input
type="checkbox"
[disabled]="readOnly()"
[ngModel]="globalInput.multiple"
(ngModelChange)="updateGlobalInput($index, { multiple: $event })" />
<span>Multiple</span>
</label>
@if (!readOnly()) {
<button
type="button"
mat-icon-button
class="title-toolbar-global-delete"
matTooltip="Delete global input"
(click)="removeGlobalInput($index)">
<mat-icon fontIcon="delete"></mat-icon>
</button>
}
</div>
@if (globalInputValidationErrors()[$index]; as errorMessage) {
<div class="title-toolbar-global-error">{{ errorMessage }}</div>
}
<div class="title-toolbar-global-syntax">
Available references: <code>{{ globalTemplateReference(globalInput.name) }}</code> and <code>{{ globalSpelReference(globalInput.name) }}</code>
</div>
}
}
}
</div>
}
@if (creatingGlobalInput()) {
<div class="title-toolbar-global-modal-backdrop" (click)="cancelNewGlobalInput()"></div>
<div class="title-toolbar-global-modal" role="dialog" aria-modal="true" aria-labelledby="global-input-modal-title">
<div class="title-toolbar-global-modal-title" id="global-input-modal-title">Add Global Input</div>
<mat-form-field appearance="outline" class="title-toolbar-global-modal-field">
<mat-label>Name</mat-label>
<input
matInput
[ngModel]="draftGlobalInput().name"
(ngModelChange)="updateDraftGlobalInput({ name: $event })"
placeholder="sharedTopic" />
</mat-form-field>
<label class="title-toolbar-global-select title-toolbar-global-modal-select">
<span>Type</span>
<select
[ngModel]="draftGlobalInput().type"
(ngModelChange)="updateDraftGlobalInput({ type: $event })">
<option value="TEXT">TEXT</option>
<option value="FILE">FILE</option>
</select>
</label>
<label class="title-toolbar-global-checkbox title-toolbar-global-modal-checkbox">
<input
type="checkbox"
[ngModel]="draftGlobalInput().multiple"
(ngModelChange)="updateDraftGlobalInput({ multiple: $event })" />
<span>Multiple</span>
</label>
@if (!draftGlobalInput().name.trim()) {
<div class="title-toolbar-global-error">Name is required</div>
} @else if (!canSaveDraftGlobalInput()) {
<div class="title-toolbar-global-error">Name must be unique</div>
}
<div class="title-toolbar-global-modal-help">
References: <code>{{ globalTemplateReference(draftGlobalInput().name) }}</code> and <code>{{ globalSpelReference(draftGlobalInput().name) }}</code>
</div>
<div class="title-toolbar-global-modal-actions">
<button type="button" mat-stroked-button (click)="cancelNewGlobalInput()">Cancel</button>
<button type="button" mat-flat-button [disabled]="!canSaveDraftGlobalInput()" (click)="saveNewGlobalInput()">Save</button>
</div>
</div>
}
@if (snackbarMessage()) {

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -8,6 +8,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { FlowGlobalInput } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { Authorization } from '@services/authorization/authorization';
import { FlowsService } from '@services/flows/flows';
@ -20,6 +21,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule, MatSlideToggleModule],
templateUrl: './title-toolbar.html',
styleUrl: './title-toolbar.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TitleToolbar {
private snackTimeout: ReturnType<typeof setTimeout> | null = null;
@ -46,7 +48,33 @@ export class TitleToolbar {
notSaved = computed(() => this.editorState.isDirty());
blockSyncInProgress = this.blocksService.hasPendingServerSync;
canSave = computed(() => !this.readOnly() && this.notSaved() && !this.blockSyncInProgress());
canSave = computed(() =>
!this.readOnly() &&
this.notSaved() &&
!this.blockSyncInProgress() &&
!this.hasGlobalInputValidationIssues()
);
globalInputs = computed(() => this.flow()?.data.globalInputs ?? []);
hasGlobalInputValidationIssues = computed(() =>
this.globalInputValidationErrors().some((message) => !!message)
);
globalInputValidationErrors = computed(() => {
const inputs = this.globalInputs();
const nameCounts = new Map<string, number>();
for (const input of inputs) {
const normalized = String(input.name ?? '').trim().toLowerCase();
if (!normalized) continue;
nameCounts.set(normalized, (nameCounts.get(normalized) ?? 0) + 1);
}
return inputs.map((input) => {
const name = String(input.name ?? '').trim();
if (!name) return 'Name is required';
if ((nameCounts.get(name.toLowerCase()) ?? 0) > 1) return 'Name must be unique';
return null;
});
});
canTogglePublished = computed(() => !!this.flow() && this.isOwner());
canFinalize = computed(() => !!this.flow() && this.isOwner() && !this.flow()!.finalized);
canExecute = computed(() => {
@ -60,6 +88,9 @@ export class TitleToolbar {
snackbarType = signal<'success' | 'error'>('success');
editingTitle = signal(false);
draftTitle = signal('');
globalInputsOpen = signal(false);
creatingGlobalInput = signal(false);
draftGlobalInput = signal<FlowGlobalInput>({ name: '', type: 'TEXT', multiple: false });
startEditingTitle() {
const flow = this.flow();
@ -96,6 +127,96 @@ export class TitleToolbar {
this.editingTitle.set(false);
}
addGlobalInput() {
if (this.readOnly()) return;
this.draftGlobalInput.set({ name: '', type: 'TEXT', multiple: false });
this.creatingGlobalInput.set(true);
}
saveNewGlobalInput() {
const flow = this.flow();
if (!flow || this.readOnly()) return;
const draft = this.draftGlobalInput();
const name = draft.name.trim();
if (!name) return;
const alreadyExists = (flow.data.globalInputs ?? []).some((input) => input.name.trim().toLowerCase() === name.toLowerCase());
if (alreadyExists) return;
this.editorState.updateData({
...flow.data,
globalInputs: [
...(flow.data.globalInputs ?? []),
{ ...draft, name }
]
});
this.creatingGlobalInput.set(false);
this.globalInputsOpen.set(true);
}
cancelNewGlobalInput() {
this.creatingGlobalInput.set(false);
}
updateDraftGlobalInput(patch: Partial<FlowGlobalInput>) {
this.draftGlobalInput.update((current) => ({
...current,
...patch
}));
}
updateGlobalInput(index: number, patch: Partial<FlowGlobalInput>) {
const flow = this.flow();
if (!flow || this.readOnly()) return;
const globalInputs = [...(flow.data.globalInputs ?? [])];
if (!globalInputs[index]) return;
globalInputs[index] = {
...globalInputs[index],
...patch
};
this.editorState.updateData({
...flow.data,
globalInputs
});
}
removeGlobalInput(index: number) {
const flow = this.flow();
if (!flow || this.readOnly()) return;
const globalInputs = [...(flow.data.globalInputs ?? [])];
globalInputs.splice(index, 1);
this.editorState.updateData({
...flow.data,
globalInputs
});
}
globalTemplateReference(name: string): string {
const resolved = name.trim() || 'name';
return `\${{global.${resolved}}}`;
}
globalSpelReference(name: string): string {
const resolved = name.trim() || 'name';
return `#global.${resolved}`;
}
toggleGlobalInputs() {
this.globalInputsOpen.update((open) => !open);
}
canSaveDraftGlobalInput(): boolean {
const flow = this.flow();
const draft = this.draftGlobalInput();
const name = draft.name.trim();
if (!flow || !name) return false;
return !(flow.data.globalInputs ?? []).some((input) => input.name.trim().toLowerCase() === name.toLowerCase());
}
save() {
if (!this.canSave()) return;
this.editorState.save().pipe(

View File

@ -49,7 +49,7 @@ export class EditorStateHolder {
this.currentFlow.set(doc);
this.isDirty.set(false);
this.validationRequiresSave.set(false);
this.applyFlowValidationErrors(doc.validationErrors ?? []);
this.applyFlowValidationErrors(doc.validationErrors ?? [], doc);
this.ensureValidationForFlow(doc);
this.clearBlockSelection();
return true;
@ -70,7 +70,7 @@ export class EditorStateHolder {
this.currentFlow.set(null);
this.isDirty.set(false);
this.validationRequiresSave.set(false);
this.applyFlowValidationErrors([]);
this.applyFlowValidationErrors([], null);
this.lastValidationFetchKey = null;
this.clearBlockSelection();
}
@ -80,7 +80,7 @@ export class EditorStateHolder {
this.currentFlow.set(flow);
this.isDirty.set(options?.markDirty === true);
this.validationRequiresSave.set(options?.markDirty === true);
this.applyFlowValidationErrors(flow.validationErrors ?? []);
this.applyFlowValidationErrors(flow.validationErrors ?? [], flow);
this.ensureValidationForFlow(flow);
this.clearBlockSelection();
}
@ -94,6 +94,7 @@ export class EditorStateHolder {
const nextFlow = { ...current, data };
this.currentFlow.set(nextFlow);
this.markDirty();
this.applyFlowValidationErrors(current.validationErrors ?? [], nextFlow);
if (options?.structural !== false) {
this.validationRequiresSave.set(true);
}
@ -170,7 +171,7 @@ export class EditorStateHolder {
};
this.currentFlow.set(nextFlow);
this.lastValidationFetchKey = this.validationFetchKey(nextFlow);
this.applyFlowValidationErrors(validationErrors);
this.applyFlowValidationErrors(validationErrors, nextFlow);
this.markSaved();
this.validationRequiresSave.set(false);
}),
@ -181,7 +182,7 @@ export class EditorStateHolder {
);
}),
catchError((error) => {
this.applyFlowValidationErrors(this.extractValidationErrors(error));
this.applyFlowValidationErrors(this.extractValidationErrors(error), this.currentFlow());
return throwError(() => error);
})
)
@ -201,11 +202,13 @@ export class EditorStateHolder {
return JSON.stringify(left) === JSON.stringify(right);
}
private applyFlowValidationErrors(errors: FlowValidationError[]) {
private applyFlowValidationErrors(errors: FlowValidationError[], flow?: Flow | null) {
const normalized = Array.isArray(errors) ? errors : [];
this.flowValidationErrors.set(normalized);
const derived = flow ? this.deriveGlobalInputReferenceErrors(flow) : [];
const merged = [...normalized, ...derived];
this.flowValidationErrors.set(merged);
this.highlightedValidationNodeIds.set(Array.from(new Set(
normalized.flatMap((error) => Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : [])
merged.flatMap((error) => Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : [])
)));
}
@ -223,6 +226,7 @@ export class EditorStateHolder {
if (!flow) return;
if (flow.status === 'EXECUTABLE') {
this.lastValidationFetchKey = this.validationFetchKey(flow);
this.applyFlowValidationErrors(flow.validationErrors ?? [], flow);
return;
}
@ -238,7 +242,10 @@ export class EditorStateHolder {
...current,
validationErrors
});
this.applyFlowValidationErrors(validationErrors);
this.applyFlowValidationErrors(validationErrors, {
...current,
validationErrors
});
},
error: (error) => {
console.error('Retrieve flow validation failed', error);
@ -249,4 +256,75 @@ export class EditorStateHolder {
private validationFetchKey(flow: Flow): string {
return `${flow.id}:${flow.status}:${flow.updatedAt?.toISOString?.() ?? ''}`;
}
private deriveGlobalInputReferenceErrors(flow: Flow): FlowValidationError[] {
const definedGlobals = new Set(
(flow.data.globalInputs ?? [])
.map((input) => String(input.name ?? '').trim())
.filter((name) => name.length > 0)
);
const occurrences = new Map<string, Set<string>>();
const collectFromValue = (value: unknown, ownerNodeId: string) => {
if (typeof value === 'string') {
const names = extractReferencedGlobalNames(value);
for (const name of names) {
if (definedGlobals.has(name)) continue;
if (!occurrences.has(name)) {
occurrences.set(name, new Set<string>());
}
occurrences.get(name)!.add(ownerNodeId);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) collectFromValue(item, ownerNodeId);
return;
}
if (value && typeof value === 'object') {
for (const item of Object.values(value as Record<string, unknown>)) {
collectFromValue(item, ownerNodeId);
}
}
};
const scanNodeConfiguration = (nodeId: string, configuration: unknown) => {
if (!configuration || typeof configuration !== 'object') return;
collectFromValue(configuration, nodeId);
};
for (const block of flow.data.blocks ?? []) {
scanNodeConfiguration(block.id, block.specificConfiguration);
}
for (const container of flow.data.containers ?? []) {
scanNodeConfiguration(container.id, container.specificConfiguration);
}
return Array.from(occurrences.entries()).map(([name, nodeIds]) => ({
code: 'GLOBAL_INPUT_NOT_DEFINED',
entity: 'flow',
field: 'globalInputs',
id: flow.id,
message: `Global input "${name}" is referenced but not defined. The flow can be saved, but it will remain DRAFT / not executable.`,
relatedNodeIds: Array.from(nodeIds)
}));
}
}
function extractReferencedGlobalNames(content: string): string[] {
const names = new Set<string>();
const templateRegex = /\$\{\{\s*global\.([A-Za-z0-9_]+)\s*\}\}/g;
const spelRegex = /#global\.([A-Za-z0-9_]+)/g;
let match: RegExpExecArray | null;
while ((match = templateRegex.exec(content)) !== null) {
names.add(match[1]);
}
while ((match = spelRegex.exec(content)) !== null) {
names.add(match[1]);
}
return Array.from(names);
}

View File

@ -11,6 +11,7 @@ import {
areFlowValueKindsCompatible,
FlowBlock,
FlowData,
FlowGlobalInput,
FLOW_DEPENDANT_PORT_KEY,
FLOW_DEPENDENCY_PORT_KEY,
FLOW_DEPENDENCY_SOCKET_TYPE,
@ -44,6 +45,7 @@ type ReteRuntimeContext = {
containersService: ContainersService;
flowState: EditorStateHolder;
readonly: boolean;
globalInputs: FlowGlobalInput[];
};
export async function createEditor(
@ -65,7 +67,8 @@ export async function createEditor(
blocksService: injector.get(BlocksService),
containersService: injector.get(ContainersService),
flowState: injector.get(EditorStateHolder),
readonly
readonly,
globalInputs: cloneValue(flowData.globalInputs ?? [])
};
editorRuntime.set(editor, runtime);
@ -144,6 +147,7 @@ export async function createEditor(
}
export function exportGraph(editor: NodeEditor<HFSchemes>) {
const runtime = editorRuntime.get(editor);
const nodeIdToBlockId = new Map<string, string>();
const nodes: FlowNode[] = editor.getNodes().map((node) => {
const blockData = node.data;
@ -182,10 +186,17 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
.map(({ kind, ...connection }) => connection),
dependencies: allConnections
.filter((connection) => connection.kind === 'dependency')
.map(({ sourceId, targetId }) => ({ sourceId, targetId }))
.map(({ sourceId, targetId }) => ({ sourceId, targetId })),
globalInputs: cloneValue(runtime?.globalInputs ?? [])
};
}
export function setEditorGlobalInputs(editor: NodeEditor<HFSchemes>, globalInputs: FlowGlobalInput[]) {
const runtime = editorRuntime.get(editor);
if (!runtime) return;
runtime.globalInputs = cloneValue(globalInputs ?? []);
}
export async function addBlockToEditor(
editor: NodeEditor<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,