diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..de7f6fe --- /dev/null +++ b/docs/user-guide.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 7e30b0d..4ef98e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 } } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 6680ab8..21e1cde 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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) ] }; diff --git a/src/app/app.ts b/src/app/app.ts index 734b29d..83c9d14 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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'); diff --git a/src/app/interceptors/auth-token.interceptor.ts b/src/app/interceptors/auth-token.interceptor.ts index 7a64dfe..621741d 100644 --- a/src/app/interceptors/auth-token.interceptor.ts +++ b/src/app/interceptors/auth-token.interceptor.ts @@ -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'); } diff --git a/src/app/layouts/admin-layout/admin-layout.ts b/src/app/layouts/admin-layout/admin-layout.ts index 250c1b0..6667e50 100644 --- a/src/app/layouts/admin-layout/admin-layout.ts +++ b/src/app/layouts/admin-layout/admin-layout.ts @@ -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 { } diff --git a/src/app/layouts/app-layout/app-layout.ts b/src/app/layouts/app-layout/app-layout.ts index f096a77..2a056e7 100644 --- a/src/app/layouts/app-layout/app-layout.ts +++ b/src/app/layouts/app-layout/app-layout.ts @@ -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() { diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index 89b477f..db3d91b 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -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: [] }; } diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index e1cc4f1..71aae30 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -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); diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 14e04f8..0a0d4fc 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -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 = { diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index b576b39..da5647f 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -30,6 +30,7 @@ export type TaskExecution = { requiredAuthorizations?: Record; providedAuthorizations?: Record; missingAuthorizationKeys?: string[]; + missingGlobalInputKeys?: string[]; }; export type TaskExecutionAuthorizationRequirement = { @@ -42,6 +43,8 @@ export type TaskExecutionAuthorizationRequirement = { export type TaskExecutionContext = { inputs: Record; + globalInputs?: Record; + globalInputDescriptors?: Record; result: Record; partialResult?: Record; 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; diff --git a/src/app/models/user.ts b/src/app/models/user.ts index 26e8728..181fd22 100644 --- a/src/app/models/user.ts +++ b/src/app/models/user.ts @@ -4,7 +4,6 @@ export type User = { username: string; email: string | null; role: UserRole; - token?: string; }; export type UserRegistration = { diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.ts b/src/app/pages/admin/admin-create-user/admin-create-user.ts index 5ac420d..1c08683 100644 --- a/src/app/pages/admin/admin-create-user/admin-create-user.ts +++ b/src/app/pages/admin/admin-create-user/admin-create-user.ts @@ -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); diff --git a/src/app/pages/admin/admin-stats/admin-stats.ts b/src/app/pages/admin/admin-stats/admin-stats.ts index 70ac3d2..e79559c 100644 --- a/src/app/pages/admin/admin-stats/admin-stats.ts +++ b/src/app/pages/admin/admin-stats/admin-stats.ts @@ -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); diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.ts b/src/app/pages/admin/admin-users-list/admin-users-list.ts index 0ae8c41..20356d6 100644 --- a/src/app/pages/admin/admin-users-list/admin-users-list.ts +++ b/src/app/pages/admin/admin-users-list/admin-users-list.ts @@ -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); diff --git a/src/app/pages/admin/admin-users/admin-users.ts b/src/app/pages/admin/admin-users/admin-users.ts index b67e223..4d5bbf3 100644 --- a/src/app/pages/admin/admin-users/admin-users.ts +++ b/src/app/pages/admin/admin-users/admin-users.ts @@ -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); diff --git a/src/app/pages/auth/login/login.ts b/src/app/pages/auth/login/login.ts index 2696f5b..b532771 100644 --- a/src/app/pages/auth/login/login.ts +++ b/src/app/pages/auth/login/login.ts @@ -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 { diff --git a/src/app/pages/auth/signup/signup.ts b/src/app/pages/auth/signup/signup.ts index 159a092..d00fcf5 100644 --- a/src/app/pages/auth/signup/signup.ts +++ b/src/app/pages/auth/signup/signup.ts @@ -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 | 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 { diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.ts b/src/app/pages/main/editor-sidebar/editor-sidebar.ts index cd5a863..2f2c405 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.ts +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.ts @@ -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 { diff --git a/src/app/services/authorization/authorization-call.base.ts b/src/app/services/authorization/authorization-call.base.ts index ad70e71..f5971dd 100644 --- a/src/app/services/authorization/authorization-call.base.ts +++ b/src/app/services/authorization/authorization-call.base.ts @@ -35,4 +35,6 @@ export abstract class AuthorizationCallServiceBase { abstract getUserStatistics(username: string): Observable; + abstract logout(): Observable; + } diff --git a/src/app/services/authorization/authorization-call.fake.ts b/src/app/services/authorization/authorization-call.fake.ts index 4b0ab80..5e0f5df 100644 --- a/src/app/services/authorization/authorization-call.fake.ts +++ b/src/app/services/authorization/authorization-call.fake.ts @@ -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 { + return of(undefined); + } } diff --git a/src/app/services/authorization/authorization-call.ts b/src/app/services/authorization/authorization-call.ts index cb87f12..8f4d0a6 100644 --- a/src/app/services/authorization/authorization-call.ts +++ b/src/app/services/authorization/authorization-call.ts @@ -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 | undefined) ?? (payload["profile"] as Record | 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 | 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 { + return this.http + .post(`${environment.apiUrl}/auth/logout`, {}) + .pipe( + map(() => undefined), + catchError(() => of(undefined)) + ); + } + private toHttpError(error: unknown, fallbackByStatus: Record): Observable { if (error instanceof HttpErrorResponse) { const message = this.extractHttpErrorMessage(error) diff --git a/src/app/services/authorization/authorization.ts b/src/app/services/authorization/authorization.ts index 7787f07..119a015 100644 --- a/src/app/services/authorization/authorization.ts +++ b/src/app/services/authorization/authorization.ts @@ -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(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 { + 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' }; } diff --git a/src/app/services/flows/flow-mapper.ts b/src/app/services/flows/flow-mapper.ts index 122ca0b..03d3ba1 100644 --- a/src/app/services/flows/flow-mapper.ts +++ b/src/app/services/flows/flow-mapper.ts @@ -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 => !!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 { if (!Array.isArray(raw)) return []; diff --git a/src/app/services/flows/flows-call.ts b/src/app/services/flows/flows-call.ts index 80ed8ef..4cf8339 100644 --- a/src/app/services/flows/flows-call.ts +++ b/src/app/services/flows/flows-call.ts @@ -33,7 +33,8 @@ export class FlowsCallService extends FlowsCallServiceBase { blocks: [], containers: [], connections: [], - dependencies: [] + dependencies: [], + globalInputs: [] }, status: 'DRAFT' }); diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts index 0d5e8c0..5b9934f 100644 --- a/src/app/services/flows/flows.ts +++ b/src/app/services/flows/flows.ts @@ -160,7 +160,8 @@ export class FlowsService { blocks: [], containers: [], connections: [], - dependencies: [] + dependencies: [], + globalInputs: [] }, status: 'DRAFT' }).pipe( diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 8d7ca71..8fdd755 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -35,6 +35,26 @@ export abstract class TaskExecutionsCallServiceBase { inputName: string, files: File[] ): Observable; + abstract prepareGlobalStringInput( + executionId: string, + inputName: string, + value: string + ): Observable; + abstract prepareGlobalStringArrayInput( + executionId: string, + inputName: string, + values: string[] + ): Observable; + abstract prepareGlobalFileInput( + executionId: string, + inputName: string, + file: File + ): Observable; + abstract prepareGlobalFileArrayInput( + executionId: string, + inputName: string, + files: File[] + ): Observable; abstract submitInteractionText( executionId: string, nodeId: string, diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index e787d19..e2268f4 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -610,6 +610,50 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(execution); } + override prepareGlobalStringInput( + executionId: string, + inputName: string, + value: string + ): Observable { + 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 { + 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 { + 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 { + 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, diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index e8c1bbb..b2015ad 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -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 { - return this.http.get(`${environment.apiUrl}/executions`); + return this.http.get(`${environment.apiUrl}/executions`).pipe( + map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecution(item)) : []) + ); } override retrieveExecutionEvents(executionId: string): Observable { @@ -18,7 +20,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { } override createTaskExecution(flowId: string): Observable { - return this.http.post(`${environment.apiUrl}/executions`, flowId); + return this.http.post(`${environment.apiUrl}/executions`, flowId).pipe( + map((raw) => this.mapExecution(raw)) + ); } override deleteTaskExecution(executionId: string): Observable { @@ -26,19 +30,27 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { } override startTaskExecution(executionId: string): Observable { - return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null); + return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null).pipe( + map((raw) => this.mapExecution(raw)) + ); } override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable { - return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator }); + return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator }).pipe( + map((raw) => this.mapExecution(raw)) + ); } override cancelTaskExecution(executionId: string): Observable { - return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null); + return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null).pipe( + map((raw) => this.mapExecution(raw)) + ); } override resumeTaskExecution(executionId: string): Observable { - return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/resume`, null); + return this.http.put(`${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 { const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/text`; - return this.http.put(url, value, { + return this.http.put(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 { const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/texts`; - return this.http.put(url, values); + return this.http.put(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(url, formData); + return this.http.put(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(url, formData); + return this.http.put(url, formData).pipe(map((raw) => this.mapExecution(raw))); + } + + override prepareGlobalStringInput( + executionId: string, + inputName: string, + value: string + ): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals/${encodeURIComponent(inputName)}`; + return this.http.put(url, JSON.stringify(value), { + headers: { 'Content-Type': 'application/json' } + }).pipe(map((raw) => this.mapExecution(raw))); + } + + override prepareGlobalStringArrayInput( + executionId: string, + inputName: string, + values: string[] + ): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals`; + return this.http.put(url, { + [inputName]: values + }).pipe(map((raw) => this.mapExecution(raw))); + } + + override prepareGlobalFileInput( + executionId: string, + inputName: string, + file: File + ): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals/${encodeURIComponent(inputName)}`; + const formData = new FormData(); + formData.append('file', file); + return this.http.put(url, formData).pipe(map((raw) => this.mapExecution(raw))); + } + + override prepareGlobalFileArrayInput( + executionId: string, + inputName: string, + files: File[] + ): Observable { + 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(url, formData).pipe(map((raw) => this.mapExecution(raw))); } override submitInteractionText( @@ -96,9 +155,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { value: string ): Observable { const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/interaction/${encodeURIComponent(fieldName)}/text`; - return this.http.put(url, value, { + return this.http.put(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 { const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/authorizations`; - return this.http.put(url, { key, value }); + return this.http.put(url, { key, value }).pipe(map((raw) => this.mapExecution(raw))); + } + + private mapExecution(raw: unknown): TaskExecution { + const execution = (raw ?? {}) as TaskExecution & Record; + const context = (execution.context ?? {}) as Record; + 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 { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + return { ...(raw as Record) }; + } + + private normalizeGlobalInputDescriptors(raw: unknown): NonNullable { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + + return Object.entries(raw as Record) + .reduce>((acc, [key, value]) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return acc; + const record = value as Record; + 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; + }, {}); } } diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index c5f905a..606e108 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -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) { diff --git a/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.html b/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.html index a57dd23..eff6eb7 100644 --- a/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.html +++ b/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.html @@ -15,7 +15,7 @@ matInput [type]="hidePassword() ? 'password' : 'text'" [field]="passwordForm.newPassword"> - @@ -35,7 +35,7 @@ matInput [type]="hideConfirmPassword() ? 'password' : 'text'" [field]="passwordForm.confirmNewPassword"> - @if (isInvalid(passwordForm.confirmNewPassword())) { diff --git a/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.ts b/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.ts index 08cfbe4..487ac98 100644 --- a/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.ts +++ b/src/app/shared/admin-reset-password-dialog/admin-reset-password-dialog.ts @@ -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(); diff --git a/src/app/shared/blocks-list/blocks-list.ts b/src/app/shared/blocks-list/blocks-list.ts index b55cc3e..35338a6 100644 --- a/src/app/shared/blocks-list/blocks-list.ts +++ b/src/app/shared/blocks-list/blocks-list.ts @@ -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 { diff --git a/src/app/shared/change-password-dialog/change-password-dialog.html b/src/app/shared/change-password-dialog/change-password-dialog.html index d6a1d1c..b6f923d 100644 --- a/src/app/shared/change-password-dialog/change-password-dialog.html +++ b/src/app/shared/change-password-dialog/change-password-dialog.html @@ -15,7 +15,7 @@ matInput [type]="hideOldPassword() ? 'password' : 'text'" [field]="passwordForm.oldPassword"> - @if (isInvalid(passwordForm.oldPassword())) { @@ -29,7 +29,7 @@ matInput [type]="hideNewPassword() ? 'password' : 'text'" [field]="passwordForm.newPassword"> - @@ -49,7 +49,7 @@ matInput [type]="hideConfirmPassword() ? 'password' : 'text'" [field]="passwordForm.confirmNewPassword"> - @if (isInvalid(passwordForm.confirmNewPassword())) { diff --git a/src/app/shared/change-password-dialog/change-password-dialog.ts b/src/app/shared/change-password-dialog/change-password-dialog.ts index 02afadc..05786cb 100644 --- a/src/app/shared/change-password-dialog/change-password-dialog.ts +++ b/src/app/shared/change-password-dialog/change-password-dialog.ts @@ -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(); diff --git a/src/app/shared/confirm-dialog/confirm-dialog.ts b/src/app/shared/confirm-dialog/confirm-dialog.ts index 57309e2..00cdfd9 100644 --- a/src/app/shared/confirm-dialog/confirm-dialog.ts +++ b/src/app/shared/confirm-dialog/confirm-dialog.ts @@ -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 { diff --git a/src/app/shared/containers-list/containers-list.ts b/src/app/shared/containers-list/containers-list.ts index 1170e8d..0c0d460 100644 --- a/src/app/shared/containers-list/containers-list.ts +++ b/src/app/shared/containers-list/containers-list.ts @@ -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 { searchTerm = model(''); diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts index 3c91330..1375818 100644 --- a/src/app/shared/custom-connection/custom-connection.ts +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -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); diff --git a/src/app/shared/custom-socket/custom-socket.ts b/src/app/shared/custom-socket/custom-socket.ts index fac7b2f..c25c961 100644 --- a/src/app/shared/custom-socket/custom-socket.ts +++ b/src/app/shared/custom-socket/custom-socket.ts @@ -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; diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts index 5e43536..fd01686 100644 --- a/src/app/shared/flow-assistant/flow-assistant.ts +++ b/src/app/shared/flow-assistant/flow-assistant.ts @@ -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); diff --git a/src/app/shared/flow-validation-panel/flow-validation-panel.ts b/src/app/shared/flow-validation-panel/flow-validation-panel.ts index 32bd71e..6446b22 100644 --- a/src/app/shared/flow-validation-panel/flow-validation-panel.ts +++ b/src/app/shared/flow-validation-panel/flow-validation-panel.ts @@ -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); diff --git a/src/app/shared/flows-list/flow-item/flow-item.ts b/src/app/shared/flows-list/flow-item/flow-item.ts index 8aa5208..d36d15a 100644 --- a/src/app/shared/flows-list/flow-item/flow-item.ts +++ b/src/app/shared/flows-list/flow-item/flow-item.ts @@ -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 { diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts index 0ff2a55..b9e913c 100644 --- a/src/app/shared/flows-list/flows-list.ts +++ b/src/app/shared/flows-list/flows-list.ts @@ -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 { filter = signal('all'); @@ -30,6 +31,9 @@ export class FlowsList extends ListStateViewHolder { { field: 'createdAt', label: 'Creation Date' } ]; + readonly orderBy = signal('name'); + readonly orderDir = signal('asc'); + searchTerm = model(''); private flowsService = inject(FlowsService); @@ -55,6 +59,9 @@ export class FlowsList extends ListStateViewHolder { 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 { 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 { 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(); + } + } diff --git a/src/app/shared/group-holder/group-holder.ts b/src/app/shared/group-holder/group-holder.ts index 49fac0c..93fcfc1 100644 --- a/src/app/shared/group-holder/group-holder.ts +++ b/src/app/shared/group-holder/group-holder.ts @@ -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 = ''; diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts index 06931f7..a0f7678 100644 --- a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts @@ -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); diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.ts b/src/app/shared/node-settings-dialog/node-settings-dialog.ts index f5d4dfe..ab026ce 100644 --- a/src/app/shared/node-settings-dialog/node-settings-dialog.ts +++ b/src/app/shared/node-settings-dialog/node-settings-dialog.ts @@ -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); diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 5235574..ebedc43 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -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); diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 8d24e4a..37942e0 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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 { diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index fe6ccb6..4b030b7 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -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 | null>>(); diff --git a/src/app/shared/ordering/ordering.ts b/src/app/shared/ordering/ordering.ts index 900f55e..4354322 100644 --- a/src/app/shared/ordering/ordering.ts +++ b/src/app/shared/ordering/ordering.ts @@ -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 { diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index 3e1f061..ae3f7fb 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -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(); @@ -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); diff --git a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts index 3960052..6e8ca3c 100644 --- a/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts +++ b/src/app/shared/subflow-preview-dialog/subflow-preview-dialog.ts @@ -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); diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index c4450e9..98b6227 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -70,10 +70,70 @@ @if (!editableInputs().length && !authorizationRequirements().length) {
No manual inputs required.
} @else { - @if (editableInputs().length) { + @if (globalExecutionInputs().length) { +
+
Globals
+
Provide the shared flow-level inputs once before execution starts.
+
+ @for (executionInput of globalExecutionInputs(); track executionInput.key) { +
+
+
{{ executionInput.title }}
+
{{ executionInput.subtitle }}
+
+
Type: {{ inputTypeLabel(executionInput) }}
+ + @if (isFileInput(executionInput)) { + + } @else { + @if (isMultipleInput(executionInput)) { +
+ @for (textValue of textValues(executionInput); track $index) { +
+ + {{ executionInput.subtitle }} {{ $index + 1 }} + + + +
+ } + +
+ +
+
+ } @else { +
+ + {{ executionInput.subtitle }} + + +
+ +
+
+ } + } + + @if (isInputSaving(executionInput.key)) { +
Saving...
+ } + @if (inputSavingError(executionInput.key); as errorMessage) { +
{{ errorMessage }}
+ } +
+ } + } + + @if (nodeExecutionInputs().length) {
Manual Inputs
} - @for (executionInput of editableInputs(); track executionInput.key) { + @for (executionInput of nodeExecutionInputs(); track executionInput.key) {
{{ executionInput.title }}
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index 3913460..0dd724e 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -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([]); @@ -37,6 +39,8 @@ export class TaskExecutionInputsPanelComponent { readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>(); readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>(); readonly authorizationSubmit = output(); + 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(); diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index bc20acb..ee0d5f0 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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({ diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index 67c0a11..cc493f2 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -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([]); diff --git a/src/app/shared/title-toolbar/title-toolbar.css b/src/app/shared/title-toolbar/title-toolbar.css index 040b356..6a54d7d 100644 --- a/src/app/shared/title-toolbar/title-toolbar.css +++ b/src/app/shared/title-toolbar/title-toolbar.css @@ -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; + } } diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html index 785caae..f1196a0 100644 --- a/src/app/shared/title-toolbar/title-toolbar.html +++ b/src/app/shared/title-toolbar/title-toolbar.html @@ -101,6 +101,132 @@ Description: {{ flow()!.description }} }
+ +
+
+
+ +
+ Use shared flow-level inputs for values reused by multiple nodes. Reference them as template {{ globalTemplateReference('name') }} or SpEL {{ globalSpelReference('name') }}. +
+
+ @if (!readOnly()) { + + } +
+ + @if (globalInputsOpen()) { + @if (!globalInputs().length) { +
No global inputs defined.
+ } @else { + @for (globalInput of globalInputs(); track $index) { +
+ + Name + + + + + + + + @if (!readOnly()) { + + } +
+ @if (globalInputValidationErrors()[$index]; as errorMessage) { +
{{ errorMessage }}
+ } +
+ Available references: {{ globalTemplateReference(globalInput.name) }} and {{ globalSpelReference(globalInput.name) }} +
+ } + } + } +
+} + +@if (creatingGlobalInput()) { +
+ } @if (snackbarMessage()) { diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index 8673171..8d2e8ca 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -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 | 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(); + + 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({ 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) { + this.draftGlobalInput.update((current) => ({ + ...current, + ...patch + })); + } + + updateGlobalInput(index: number, patch: Partial) { + 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( diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts index a7c132c..2b4a76f 100644 --- a/src/app/stores/flow-editor.ts +++ b/src/app/stores/flow-editor.ts @@ -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>(); + + 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()); + } + 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)) { + 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(); + 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); } diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 402d7cc..1262a14 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -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) { + const runtime = editorRuntime.get(editor); const nodeIdToBlockId = new Map(); const nodes: FlowNode[] = editor.getNodes().map((node) => { const blockData = node.data; @@ -182,10 +186,17 @@ export function exportGraph(editor: NodeEditor) { .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, globalInputs: FlowGlobalInput[]) { + const runtime = editorRuntime.get(editor); + if (!runtime) return; + runtime.globalInputs = cloneValue(globalInputs ?? []); +} + export async function addBlockToEditor( editor: NodeEditor, area: AreaPlugin,