feat: improve auth flow and task execution UX
This commit is contained in:
parent
bb1cdd52ac
commit
7028ac0c53
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type TaskExecution = {
|
|||
requiredAuthorizations?: Record<string, TaskExecutionAuthorizationRequirement>;
|
||||
providedAuthorizations?: Record<string, unknown>;
|
||||
missingAuthorizationKeys?: string[];
|
||||
missingGlobalInputKeys?: string[];
|
||||
};
|
||||
|
||||
export type TaskExecutionAuthorizationRequirement = {
|
||||
|
|
@ -42,6 +43,8 @@ export type TaskExecutionAuthorizationRequirement = {
|
|||
|
||||
export type TaskExecutionContext = {
|
||||
inputs: Record<string, unknown>;
|
||||
globalInputs?: Record<string, unknown>;
|
||||
globalInputDescriptors?: Record<string, TaskExecutionGlobalInputDescriptor>;
|
||||
result: Record<string, unknown>;
|
||||
partialResult?: Record<string, unknown>;
|
||||
startTime?: number | null;
|
||||
|
|
@ -53,6 +56,15 @@ export type TaskExecutionContext = {
|
|||
waitingSteps: string[];
|
||||
};
|
||||
|
||||
export type TaskExecutionGlobalInputDescriptor = {
|
||||
name: string;
|
||||
kind: string;
|
||||
value: unknown;
|
||||
description?: string | null;
|
||||
cleanupPolicy?: string | null;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
export type TaskExecutionStep = {
|
||||
node?: FlowNode;
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ export type User = {
|
|||
username: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type UserRegistration = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AfterViewInit, Component, effect, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { AfterViewInit, ChangeDetectionStrategy, Component, effect, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
|
|
@ -39,6 +39,7 @@ let turnstileScriptPromise: Promise<void> | null = null;
|
|||
imports: [FormsModule, RouterLink, Field, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule],
|
||||
templateUrl: './signup.html',
|
||||
styleUrl: './signup.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class Signup extends FormUtility implements AfterViewInit, OnDestroy {
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -35,4 +35,6 @@ export abstract class AuthorizationCallServiceBase {
|
|||
|
||||
abstract getUserStatistics(username: string): Observable<UserStatistics>;
|
||||
|
||||
abstract logout(): Observable<void>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Observable } from "rxjs";
|
||||
import { Observable, of } from "rxjs";
|
||||
import { AuthorizationCallServiceBase } from "./authorization-call.base";
|
||||
import {
|
||||
AdminChangeRoleRequest,
|
||||
|
|
@ -35,8 +35,7 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
|
|||
observer.next({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: 'fake-jwt-token'
|
||||
role: user.role
|
||||
});
|
||||
observer.complete();
|
||||
});
|
||||
|
|
@ -220,4 +219,8 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
|
|||
lastExecutionAt: 1774520966139
|
||||
};
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
return of(undefined);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
UserStatistics,
|
||||
UserRole
|
||||
} from "@models/user";
|
||||
import { catchError, map, Observable, throwError } from "rxjs";
|
||||
import { catchError, map, Observable, of, throwError } from "rxjs";
|
||||
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
|
||||
import { inject } from "@angular/core";
|
||||
import { environment } from "@environment";
|
||||
|
|
@ -29,13 +29,11 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
|
|||
(payload["user"] as Record<string, unknown> | undefined)
|
||||
?? (payload["profile"] as Record<string, unknown> | undefined)
|
||||
?? payload;
|
||||
const token = this.extractToken(payload, userSource);
|
||||
|
||||
return {
|
||||
username: String(userSource["username"] ?? username),
|
||||
email: typeof userSource["email"] === "string" ? String(userSource["email"]) : null,
|
||||
role: this.normalizeRole(userSource["role"]),
|
||||
token: typeof token === "string" && token.length > 0 ? token : undefined
|
||||
role: this.normalizeRole(userSource["role"])
|
||||
} satisfies User;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
|
|
@ -198,20 +196,6 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
|
|||
return null;
|
||||
}
|
||||
|
||||
private extractToken(...sources: Array<Record<string, unknown> | undefined>): unknown {
|
||||
const tokenKeys = ['token', 'accessToken', 'access_token', 'jwt', 'id_token'];
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
for (const key of tokenKeys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private normalizeRole(value: unknown): UserRole {
|
||||
return String(value ?? '').toUpperCase() === 'ADMIN' ? 'ADMIN' : 'USER';
|
||||
}
|
||||
|
|
@ -259,6 +243,15 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
|
|||
};
|
||||
}
|
||||
|
||||
override logout(): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${environment.apiUrl}/auth/logout`, {})
|
||||
.pipe(
|
||||
map(() => undefined),
|
||||
catchError(() => of(undefined))
|
||||
);
|
||||
}
|
||||
|
||||
private toHttpError(error: unknown, fallbackByStatus: Record<number, string>): Observable<never> {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const message = this.extractHttpErrorMessage(error)
|
||||
|
|
|
|||
|
|
@ -11,14 +11,13 @@ import {
|
|||
} from '@models/user';
|
||||
import { AuthorizationCallServiceBase } from './authorization-call.base';
|
||||
import { environment } from '@environment';
|
||||
import { take, tap } from 'rxjs';
|
||||
import { Observable, take, tap } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Authorization {
|
||||
static readonly USER_STORAGE_KEY = 'loggedInUser';
|
||||
static readonly TOKEN_STORAGE_KEY = 'authToken';
|
||||
|
||||
private user = signal<User | null>(null);
|
||||
|
||||
|
|
@ -31,15 +30,9 @@ export class Authorization {
|
|||
take(1),
|
||||
tap(res => {
|
||||
const normalizedUser = this.normalizeUser(res);
|
||||
if (normalizedUser) {
|
||||
console.log('[auth] logged user role:', normalizedUser.role);
|
||||
}
|
||||
this.user.set(normalizedUser);
|
||||
localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(normalizedUser));
|
||||
if (res.token) {
|
||||
localStorage.setItem(Authorization.TOKEN_STORAGE_KEY, res.token);
|
||||
} else {
|
||||
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(normalizedUser));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
@ -105,22 +98,22 @@ export class Authorization {
|
|||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
|
||||
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
|
||||
this.user.set(null);
|
||||
}
|
||||
|
||||
token(): string | null {
|
||||
return localStorage.getItem(Authorization.TOKEN_STORAGE_KEY);
|
||||
logout(): Observable<void> {
|
||||
return this.authCall.logout().pipe(
|
||||
take(1),
|
||||
tap(() => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
|
||||
}
|
||||
this.user.set(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
const storedUser = localStorage.getItem(Authorization.USER_STORAGE_KEY);
|
||||
const normalizedUser = storedUser != null ? this.normalizeUser(JSON.parse(storedUser) as User) : null;
|
||||
if (normalizedUser) {
|
||||
console.log('[auth] restored user role:', normalizedUser.role);
|
||||
}
|
||||
this.user.set(normalizedUser);
|
||||
return storedUser != null;
|
||||
}
|
||||
|
|
@ -135,8 +128,7 @@ export class Authorization {
|
|||
return {
|
||||
username: String(user.username ?? ''),
|
||||
email: typeof user.email === 'string' ? user.email : null,
|
||||
role: user.role === 'ADMIN' ? 'ADMIN' : 'USER',
|
||||
token: user.token
|
||||
role: user.role === 'ADMIN' ? 'ADMIN' : 'USER'
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Flow, FlowBlock, FlowContainer, FlowData, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
|
||||
import { Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
|
||||
|
||||
function parseDate(value: unknown, fallback: Date): Date {
|
||||
if (typeof value !== 'string' || !value) return fallback;
|
||||
|
|
@ -35,7 +35,8 @@ export function flowFromApi(raw: unknown): Flow {
|
|||
blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[],
|
||||
containers: normalizeNodes(data.containers, 'container') as FlowContainer[],
|
||||
connections: Array.isArray(data.connections) ? data.connections : [],
|
||||
dependencies: Array.isArray(data.dependencies) ? data.dependencies : []
|
||||
dependencies: Array.isArray(data.dependencies) ? data.dependencies : [],
|
||||
globalInputs: normalizeGlobalInputs(data.globalInputs)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -49,11 +50,24 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F
|
|||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
dependencies: [],
|
||||
globalInputs: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGlobalInputs(raw: unknown): FlowGlobalInput[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item))
|
||||
.map((item) => ({
|
||||
name: String(item['name'] ?? '').trim(),
|
||||
type: String(item['type'] ?? 'TEXT').toUpperCase() || 'TEXT',
|
||||
multiple: Boolean(item['multiple'])
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container'): Array<FlowBlock | FlowContainer> {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ export class FlowsCallService extends FlowsCallServiceBase {
|
|||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
dependencies: [],
|
||||
globalInputs: []
|
||||
},
|
||||
status: 'DRAFT'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ export class FlowsService {
|
|||
blocks: [],
|
||||
containers: [],
|
||||
connections: [],
|
||||
dependencies: []
|
||||
dependencies: [],
|
||||
globalInputs: []
|
||||
},
|
||||
status: 'DRAFT'
|
||||
}).pipe(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,26 @@ export abstract class TaskExecutionsCallServiceBase {
|
|||
inputName: string,
|
||||
files: File[]
|
||||
): Observable<TaskExecution>;
|
||||
abstract prepareGlobalStringInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
value: string
|
||||
): Observable<TaskExecution>;
|
||||
abstract prepareGlobalStringArrayInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
): Observable<TaskExecution>;
|
||||
abstract prepareGlobalFileInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
file: File
|
||||
): Observable<TaskExecution>;
|
||||
abstract prepareGlobalFileArrayInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
files: File[]
|
||||
): Observable<TaskExecution>;
|
||||
abstract submitInteractionText(
|
||||
executionId: string,
|
||||
nodeId: string,
|
||||
|
|
|
|||
|
|
@ -610,6 +610,50 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return of(execution);
|
||||
}
|
||||
|
||||
override prepareGlobalStringInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
value: string
|
||||
): Observable<TaskExecution> {
|
||||
const execution = this.findExecution(executionId);
|
||||
execution.context.inputs[`global:${inputName}`] = value;
|
||||
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
|
||||
return of(execution);
|
||||
}
|
||||
|
||||
override prepareGlobalStringArrayInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
): Observable<TaskExecution> {
|
||||
const execution = this.findExecution(executionId);
|
||||
execution.context.inputs[`global:${inputName}`] = values;
|
||||
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
|
||||
return of(execution);
|
||||
}
|
||||
|
||||
override prepareGlobalFileInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
file: File
|
||||
): Observable<TaskExecution> {
|
||||
const execution = this.findExecution(executionId);
|
||||
execution.context.inputs[`global:${inputName}`] = file?.name ?? '';
|
||||
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
|
||||
return of(execution);
|
||||
}
|
||||
|
||||
override prepareGlobalFileArrayInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
files: File[]
|
||||
): Observable<TaskExecution> {
|
||||
const execution = this.findExecution(executionId);
|
||||
execution.context.inputs[`global:${inputName}`] = files.map((file) => file?.name ?? '');
|
||||
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status;
|
||||
return of(execution);
|
||||
}
|
||||
|
||||
override submitInteractionText(
|
||||
executionId: string,
|
||||
nodeId: string,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { inject } from '@angular/core';
|
|||
import { environment } from '@environment';
|
||||
import { LLMDescriptor } from '@models/flow';
|
||||
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
|
||||
|
||||
export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
override retrieveAllTaskExecutions(): Observable<TaskExecution[]> {
|
||||
return this.http.get<TaskExecution[]>(`${environment.apiUrl}/executions`);
|
||||
return this.http.get<unknown[]>(`${environment.apiUrl}/executions`).pipe(
|
||||
map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecution(item)) : [])
|
||||
);
|
||||
}
|
||||
|
||||
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
|
||||
|
|
@ -18,7 +20,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
}
|
||||
|
||||
override createTaskExecution(flowId: string): Observable<TaskExecution> {
|
||||
return this.http.post<TaskExecution>(`${environment.apiUrl}/executions`, flowId);
|
||||
return this.http.post<unknown>(`${environment.apiUrl}/executions`, flowId).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
);
|
||||
}
|
||||
|
||||
override deleteTaskExecution(executionId: string): Observable<void> {
|
||||
|
|
@ -26,19 +30,27 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
}
|
||||
|
||||
override startTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null);
|
||||
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
);
|
||||
}
|
||||
|
||||
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution> {
|
||||
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator });
|
||||
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator }).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
);
|
||||
}
|
||||
|
||||
override cancelTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null);
|
||||
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
);
|
||||
}
|
||||
|
||||
override resumeTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/resume`, null);
|
||||
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/resume`, null).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
);
|
||||
}
|
||||
|
||||
override prepareStringInput(
|
||||
|
|
@ -48,9 +60,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
value: string
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/text`;
|
||||
return this.http.put<TaskExecution>(url, value, {
|
||||
return this.http.put<unknown>(url, value, {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override prepareStringArrayInput(
|
||||
|
|
@ -60,7 +72,7 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
values: string[]
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/texts`;
|
||||
return this.http.put<TaskExecution>(url, values);
|
||||
return this.http.put<unknown>(url, values).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override prepareFileInput(
|
||||
|
|
@ -72,7 +84,7 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/file`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.put<TaskExecution>(url, formData);
|
||||
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override prepareFileArrayInput(
|
||||
|
|
@ -86,7 +98,54 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
for (const file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
return this.http.put<TaskExecution>(url, formData);
|
||||
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override prepareGlobalStringInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
value: string
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals/${encodeURIComponent(inputName)}`;
|
||||
return this.http.put<unknown>(url, JSON.stringify(value), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override prepareGlobalStringArrayInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
values: string[]
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals`;
|
||||
return this.http.put<unknown>(url, {
|
||||
[inputName]: values
|
||||
}).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override prepareGlobalFileInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
file: File
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals/${encodeURIComponent(inputName)}`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override prepareGlobalFileArrayInput(
|
||||
executionId: string,
|
||||
inputName: string,
|
||||
files: File[]
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/globals`;
|
||||
const formData = new FormData();
|
||||
formData.append('key', inputName);
|
||||
for (const file of files) {
|
||||
formData.append(inputName, file);
|
||||
}
|
||||
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override submitInteractionText(
|
||||
|
|
@ -96,9 +155,9 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
value: string
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/interaction/${encodeURIComponent(fieldName)}/text`;
|
||||
return this.http.put<TaskExecution>(url, value, {
|
||||
return this.http.put<unknown>(url, value, {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override provideAuthorization(
|
||||
|
|
@ -107,6 +166,55 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
value: string
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/authorizations`;
|
||||
return this.http.put<TaskExecution>(url, { key, value });
|
||||
return this.http.put<unknown>(url, { key, value }).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
private mapExecution(raw: unknown): TaskExecution {
|
||||
const execution = (raw ?? {}) as TaskExecution & Record<string, any>;
|
||||
const context = (execution.context ?? {}) as Record<string, any>;
|
||||
const globalInputs = this.normalizeGlobalInputValues(
|
||||
context['globalInputs']
|
||||
?? execution['globalInputs']
|
||||
);
|
||||
const globalInputDescriptors = this.normalizeGlobalInputDescriptors(
|
||||
context['globalInputDescriptors']
|
||||
?? execution['globalInputDescriptors']
|
||||
);
|
||||
|
||||
return {
|
||||
...execution,
|
||||
context: {
|
||||
...(context as TaskExecution['context']),
|
||||
globalInputs,
|
||||
globalInputDescriptors
|
||||
} as TaskExecution['context']
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeGlobalInputValues(raw: unknown): Record<string, unknown> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
return { ...(raw as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
private normalizeGlobalInputDescriptors(raw: unknown): NonNullable<TaskExecution['context']['globalInputDescriptors']> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
|
||||
return Object.entries(raw as Record<string, unknown>)
|
||||
.reduce<NonNullable<TaskExecution['context']['globalInputDescriptors']>>((acc, [key, value]) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return acc;
|
||||
const record = value as Record<string, unknown>;
|
||||
const name = String(record['name'] ?? key).trim();
|
||||
if (!name) return acc;
|
||||
|
||||
acc[key] = {
|
||||
name,
|
||||
kind: String(record['kind'] ?? record['type'] ?? 'TEXT').toUpperCase(),
|
||||
value: record['value'] ?? null,
|
||||
description: typeof record['description'] === 'string' ? record['description'] : null,
|
||||
cleanupPolicy: typeof record['cleanupPolicy'] === 'string' ? record['cleanupPolicy'] : null,
|
||||
multiple: Boolean(record['multiple'])
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
matInput
|
||||
[type]="hidePassword() ? 'password' : 'text'"
|
||||
[field]="passwordForm.newPassword">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hidePassword.set(!hidePassword())">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hidePassword.set(!hidePassword())" [attr.aria-label]="hidePassword() ? 'Show password' : 'Hide password'">
|
||||
<mat-icon [fontIcon]="hidePassword() ? 'visibility' : 'visibility_off'"></mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
matInput
|
||||
[type]="hideConfirmPassword() ? 'password' : 'text'"
|
||||
[field]="passwordForm.confirmNewPassword">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())" [attr.aria-label]="hideConfirmPassword() ? 'Show confirm password' : 'Hide confirm password'">
|
||||
<mat-icon [fontIcon]="hideConfirmPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
|
||||
</button>
|
||||
@if (isInvalid(passwordForm.confirmNewPassword())) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
|
@ -19,7 +19,8 @@ function hasValidPasswordComplexity(value: string): boolean {
|
|||
selector: 'app-admin-reset-password-dialog',
|
||||
imports: [FormsModule, Field, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule],
|
||||
templateUrl: './admin-reset-password-dialog.html',
|
||||
styleUrl: './admin-reset-password-dialog.css'
|
||||
styleUrl: './admin-reset-password-dialog.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminResetPasswordDialogComponent extends FormUtility {
|
||||
readonly username = input.required<string>();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { BlockType } from '@models/flow';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
|
||||
|
|
@ -17,6 +17,7 @@ import { BLOCK_TYPE_DRAG_MIME } from './block-drag';
|
|||
imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
|
||||
templateUrl: './blocks-list.html',
|
||||
styleUrl: './blocks-list.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BlocksList extends ListStateViewHolder<BlockType> {
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
matInput
|
||||
[type]="hideOldPassword() ? 'password' : 'text'"
|
||||
[field]="passwordForm.oldPassword">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideOldPassword.set(!hideOldPassword())">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideOldPassword.set(!hideOldPassword())" [attr.aria-label]="hideOldPassword() ? 'Show current password' : 'Hide current password'">
|
||||
<mat-icon [fontIcon]="hideOldPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
|
||||
</button>
|
||||
@if (isInvalid(passwordForm.oldPassword())) {
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
matInput
|
||||
[type]="hideNewPassword() ? 'password' : 'text'"
|
||||
[field]="passwordForm.newPassword">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideNewPassword.set(!hideNewPassword())">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideNewPassword.set(!hideNewPassword())" [attr.aria-label]="hideNewPassword() ? 'Show new password' : 'Hide new password'">
|
||||
<mat-icon [fontIcon]="hideNewPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
matInput
|
||||
[type]="hideConfirmPassword() ? 'password' : 'text'"
|
||||
[field]="passwordForm.confirmNewPassword">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())">
|
||||
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())" [attr.aria-label]="hideConfirmPassword() ? 'Show confirm password' : 'Hide confirm password'">
|
||||
<mat-icon [fontIcon]="hideConfirmPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
|
||||
</button>
|
||||
@if (isInvalid(passwordForm.confirmNewPassword())) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
|
@ -20,7 +20,8 @@ function hasValidPasswordComplexity(value: string): boolean {
|
|||
selector: 'app-change-password-dialog',
|
||||
imports: [FormsModule, Field, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule],
|
||||
templateUrl: './change-password-dialog.html',
|
||||
styleUrl: './change-password-dialog.css'
|
||||
styleUrl: './change-password-dialog.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChangePasswordDialogComponent extends FormUtility {
|
||||
readonly username = input.required<string>();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { BlockType } from '@models/flow';
|
||||
import { ContainersService } from '@services/containers/containers';
|
||||
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
|
||||
|
|
@ -17,6 +17,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
|
|||
imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
|
||||
templateUrl: './containers-list.html',
|
||||
styleUrl: './containers-list.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ContainersList extends ListStateViewHolder<BlockType> {
|
||||
searchTerm = model<string>('');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, model, signal, Signal, WritableSignal } from '@angular/core';
|
||||
import { Flow, FlowVisibility } from '@models/flow';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
import { FlowItem } from './flow-item/flow-item';
|
||||
|
|
@ -10,7 +10,7 @@ import { MatIconModule } from '@angular/material/icon';
|
|||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { OrderEvent, OrderField, Ordering } from "@shared/ordering/ordering";
|
||||
import { OrderEvent, OrderField, Ordering, orderDirType } from "@shared/ordering/ordering";
|
||||
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
|
||||
|
||||
type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
|
||||
|
|
@ -20,6 +20,7 @@ type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
|
|||
imports: [FlowItem, FormsModule, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule],
|
||||
templateUrl: './flows-list.html',
|
||||
styleUrl: './flows-list.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FlowsList extends ListStateViewHolder<Flow> {
|
||||
filter = signal<FlowFilter>('all');
|
||||
|
|
@ -30,6 +31,9 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
{ field: 'createdAt', label: 'Creation Date' }
|
||||
];
|
||||
|
||||
readonly orderBy = signal<string | null>('name');
|
||||
readonly orderDir = signal<orderDirType>('asc');
|
||||
|
||||
searchTerm = model<string>('');
|
||||
|
||||
private flowsService = inject(FlowsService);
|
||||
|
|
@ -55,6 +59,9 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
|
||||
ngOnInit() {
|
||||
const existingState = this.view;
|
||||
this.orderBy.set(existingState.order.orderBy);
|
||||
this.orderDir.set(existingState.order.orderDir);
|
||||
|
||||
if (existingState.list) {
|
||||
this.flows = existingState.list;
|
||||
this.loading.set(false);
|
||||
|
|
@ -91,12 +98,13 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
|
||||
orderedFlows = computed(() => {
|
||||
const flows = [...this.filteredFlows()];
|
||||
const { orderBy, orderDir } = this.view.order;
|
||||
const orderBy = this.orderBy();
|
||||
const orderDir = this.orderDir();
|
||||
if (!orderBy) return flows;
|
||||
|
||||
return flows.sort((a, b) => {
|
||||
const aValue = (a as any)[orderBy];
|
||||
const bValue = (b as any)[orderBy];
|
||||
const aValue = this.toComparableValue((a as any)[orderBy]);
|
||||
const bValue = this.toComparableValue((b as any)[orderBy]);
|
||||
if (aValue == null && bValue == null) return 0;
|
||||
if (aValue == null) return orderDir === 'asc' ? -1 : 1;
|
||||
if (bValue == null) return orderDir === 'asc' ? 1 : -1;
|
||||
|
|
@ -109,7 +117,17 @@ export class FlowsList extends ListStateViewHolder<Flow> {
|
|||
|
||||
onOrderChanged(event: OrderEvent) {
|
||||
const { orderBy, orderDir } = event;
|
||||
this.orderBy.set(orderBy);
|
||||
this.orderDir.set(orderDir);
|
||||
this.view.order = { orderBy, orderDir };
|
||||
}
|
||||
|
||||
private toComparableValue(value: unknown): string | number | null {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Date) return value.getTime();
|
||||
if (typeof value === 'string') return value.toLowerCase();
|
||||
if (typeof value === 'number') return value;
|
||||
return String(value).toLowerCase();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { ReteModule } from 'rete-angular-plugin/21';
|
||||
import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY } from '@models/flow';
|
||||
|
|
@ -86,7 +86,8 @@ type FieldUiMeta = {
|
|||
styleUrl: './task-step-node.css',
|
||||
host: {
|
||||
'data-testid': 'task-step-node'
|
||||
}
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TaskStepNodeComponent {
|
||||
private static readonly globalFieldSchemaCache = new Map<string, Map<string, Record<string, any> | null>>();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, untracked, ViewChild } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, untracked, ViewChild } from '@angular/core';
|
||||
import { BlockType, FlowData, FlowNode } from '@models/flow';
|
||||
import { Drag } from 'rete-area-plugin';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
|
|
@ -7,7 +7,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
|
|||
import { CONTAINER_SUBFLOW_DRAG_MIME } from '@shared/nodes/container-node/container-node-drag';
|
||||
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance } from '@utilities/rete-editor';
|
||||
import { addBlockToEditor, createEditor, exportGraph, ReteEditorInstance, setEditorGlobalInputs } from '@utilities/rete-editor';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
|
|
@ -15,6 +15,7 @@ import { firstValueFrom } from 'rxjs';
|
|||
imports: [],
|
||||
templateUrl: './rete-editor.html',
|
||||
styleUrl: './rete-editor.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReteEditor implements OnChanges, OnDestroy {
|
||||
readonly flowData = input.required<FlowData>();
|
||||
|
|
@ -75,6 +76,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
void this.reloadEditor();
|
||||
return;
|
||||
}
|
||||
if (changes['flowData'] && this.rete) {
|
||||
setEditorGlobalInputs(this.rete.editor, this.flowData().globalInputs ?? []);
|
||||
}
|
||||
if (this.readonly() && changes['flowData']) {
|
||||
void this.syncReadonlyFlowData();
|
||||
}
|
||||
|
|
@ -301,6 +305,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
this.rete = rete;
|
||||
setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []);
|
||||
this.syncAreaDragMode();
|
||||
const loadedFlowId = this.flowId();
|
||||
const normalizedData = exportGraph(rete.editor);
|
||||
|
|
@ -355,6 +360,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
}
|
||||
|
||||
const nextFlowData = this.flowData();
|
||||
setEditorGlobalInputs(rete.editor, nextFlowData.globalInputs ?? []);
|
||||
if (!this.canPatchReadonlyFlowData(rete, nextFlowData)) {
|
||||
await this.reloadEditor();
|
||||
return;
|
||||
|
|
@ -422,7 +428,17 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
.sort();
|
||||
|
||||
if (currentDependencies.length !== nextDependencies.length) return false;
|
||||
return currentDependencies.every((dependency, index) => dependency === nextDependencies[index]);
|
||||
if (!currentDependencies.every((dependency, index) => dependency === nextDependencies[index])) return false;
|
||||
|
||||
const currentGlobalInputs = [...(currentFlowData.globalInputs ?? [])]
|
||||
.map((input) => `${input.name}:${String(input.type ?? '').toUpperCase()}:${input.multiple ? 'multi' : 'single'}`)
|
||||
.sort();
|
||||
const nextGlobalInputs = [...(nextFlowData.globalInputs ?? [])]
|
||||
.map((input) => `${input.name}:${String(input.type ?? '').toUpperCase()}:${input.multiple ? 'multi' : 'single'}`)
|
||||
.sort();
|
||||
|
||||
if (currentGlobalInputs.length !== nextGlobalInputs.length) return false;
|
||||
return currentGlobalInputs.every((input, index) => input === nextGlobalInputs[index]);
|
||||
}
|
||||
|
||||
private async patchReadonlyNodes(rete: ReteEditorInstance, nextFlowData: FlowData) {
|
||||
|
|
@ -513,6 +529,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
this.syncNodePositionFromContext(rete, context);
|
||||
if (this.flowState.currentFlow()?.id !== loadedFlowId) return;
|
||||
|
||||
setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []);
|
||||
const updatedData = exportGraph(rete.editor);
|
||||
this.flowState.updateData(updatedData, { structural: context?.type !== 'nodetranslated' });
|
||||
this.flowChanged.emit(updatedData);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -70,10 +70,70 @@
|
|||
@if (!editableInputs().length && !authorizationRequirements().length) {
|
||||
<div class="text-xs text-slate-500">No manual inputs required.</div>
|
||||
} @else {
|
||||
@if (editableInputs().length) {
|
||||
@if (globalExecutionInputs().length) {
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Globals</div>
|
||||
<div class="mt-1 text-[11px] text-slate-500">Provide the shared flow-level inputs once before execution starts.</div>
|
||||
</div>
|
||||
@for (executionInput of globalExecutionInputs(); track executionInput.key) {
|
||||
<div class="rounded-md border border-violet-200 bg-violet-50/60 p-2">
|
||||
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-violet-200 bg-gradient-to-b from-violet-50 to-violet-100 px-3 py-2 text-center text-violet-700">
|
||||
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
|
||||
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-violet-600">{{ executionInput.subtitle }}</div>
|
||||
</div>
|
||||
<div class="mb-2 text-[10px] text-slate-500">Type: {{ inputTypeLabel(executionInput) }}</div>
|
||||
|
||||
@if (isFileInput(executionInput)) {
|
||||
<input
|
||||
type="file"
|
||||
class="block w-full text-sm text-slate-700"
|
||||
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
|
||||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
} @else {
|
||||
@if (isMultipleInput(executionInput)) {
|
||||
<div class="space-y-3">
|
||||
@for (textValue of textValues(executionInput); track $index) {
|
||||
<div class="flex items-start gap-2">
|
||||
<mat-form-field appearance="outline" class="flex-1">
|
||||
<mat-label>{{ executionInput.subtitle }} {{ $index + 1 }}</mat-label>
|
||||
<textarea matInput rows="3" [readonly]="readOnly()" [ngModel]="textValue" (ngModelChange)="updateTextItem(executionInput, $index, $event)"></textarea>
|
||||
</mat-form-field>
|
||||
<button type="button" mat-stroked-button class="shrink-0" [disabled]="readOnly() || textValues(executionInput).length <= 1" (click)="removeTextItem(executionInput, $index)">Remove</button>
|
||||
</div>
|
||||
}
|
||||
<button type="button" mat-stroked-button [disabled]="readOnly()" (click)="addTextItem(executionInput)">Add item</button>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-3">
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>{{ executionInput.subtitle }}</mat-label>
|
||||
<textarea matInput rows="4" [readonly]="readOnly()" [ngModel]="executionInput.value" (ngModelChange)="onTextInputChange(executionInput, $event)"></textarea>
|
||||
</mat-form-field>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" mat-flat-button [disabled]="!canSubmitTextInput(executionInput)" (click)="submitTextInput(executionInput, $event)">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isInputSaving(executionInput.key)) {
|
||||
<div class="mt-2 text-[11px] text-blue-600">Saving...</div>
|
||||
}
|
||||
@if (inputSavingError(executionInput.key); as errorMessage) {
|
||||
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (nodeExecutionInputs().length) {
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Manual Inputs</div>
|
||||
}
|
||||
@for (executionInput of editableInputs(); track executionInput.key) {
|
||||
@for (executionInput of nodeExecutionInputs(); track executionInput.key) {
|
||||
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||
<div class="mb-3 flex min-h-[52px] flex-col items-center justify-center rounded-[10px] border border-blue-200 bg-gradient-to-b from-blue-50 to-blue-100 px-3 py-2 text-center text-blue-700">
|
||||
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
|
|
@ -8,7 +8,8 @@ import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
|
|||
|
||||
export type EditableExecutionInput = {
|
||||
key: string;
|
||||
nodeId: string;
|
||||
scope: 'global' | 'node';
|
||||
nodeId: string | null;
|
||||
inputName: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
|
|
@ -20,7 +21,8 @@ export type EditableExecutionInput = {
|
|||
@Component({
|
||||
selector: 'app-task-execution-inputs-panel',
|
||||
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
||||
templateUrl: './task-execution-inputs-panel.html'
|
||||
templateUrl: './task-execution-inputs-panel.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TaskExecutionInputsPanelComponent {
|
||||
readonly editableInputs = input<EditableExecutionInput[]>([]);
|
||||
|
|
@ -37,6 +39,8 @@ export class TaskExecutionInputsPanelComponent {
|
|||
readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>();
|
||||
readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
|
||||
readonly authorizationSubmit = output<TaskExecutionAuthorizationRequirement>();
|
||||
readonly globalExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'global'));
|
||||
readonly nodeExecutionInputs = computed(() => this.editableInputs().filter((input) => input.scope === 'node'));
|
||||
|
||||
private readonly authorizationVisibility = new Map<string, boolean>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, input, model, output, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, input, model, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
|
|
@ -30,6 +30,7 @@ export type TaskExecutionListItem = {
|
|||
imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule],
|
||||
templateUrl: './tasks-executions-list.html',
|
||||
styleUrl: './tasks-executions-list.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TasksExecutionsListComponent {
|
||||
readonly executions = input<TaskExecutionListItem[]>([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,132 @@
|
|||
<span class="title-toolbar-description"><span class="title-toolbar-meta-label">Description:</span> {{ flow()!.description }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="title-toolbar-global-inputs">
|
||||
<div class="title-toolbar-global-inputs-header">
|
||||
<div>
|
||||
<button type="button" class="title-toolbar-global-inputs-toggle" (click)="toggleGlobalInputs()">
|
||||
<mat-icon [fontIcon]="globalInputsOpen() ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<span class="title-toolbar-global-inputs-title">Global Inputs</span>
|
||||
<span class="title-toolbar-global-count">{{ globalInputs().length }}</span>
|
||||
</button>
|
||||
<div class="title-toolbar-global-inputs-help">
|
||||
Use shared flow-level inputs for values reused by multiple nodes. Reference them as template <code>{{ globalTemplateReference('name') }}</code> or SpEL <code>{{ globalSpelReference('name') }}</code>.
|
||||
</div>
|
||||
</div>
|
||||
@if (!readOnly()) {
|
||||
<button type="button" mat-icon-button matTooltip="Add global input" (click)="addGlobalInput()">
|
||||
<mat-icon fontIcon="add"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (globalInputsOpen()) {
|
||||
@if (!globalInputs().length) {
|
||||
<div class="title-toolbar-global-inputs-empty">No global inputs defined.</div>
|
||||
} @else {
|
||||
@for (globalInput of globalInputs(); track $index) {
|
||||
<div class="title-toolbar-global-input-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="globalInput.name"
|
||||
(ngModelChange)="updateGlobalInput($index, { name: $event })"
|
||||
placeholder="sharedTopic" />
|
||||
</mat-form-field>
|
||||
|
||||
<label class="title-toolbar-global-select">
|
||||
<span>Type</span>
|
||||
<select
|
||||
[disabled]="readOnly()"
|
||||
[ngModel]="globalInput.type"
|
||||
(ngModelChange)="updateGlobalInput($index, { type: $event })">
|
||||
<option value="TEXT">TEXT</option>
|
||||
<option value="FILE">FILE</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="title-toolbar-global-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[disabled]="readOnly()"
|
||||
[ngModel]="globalInput.multiple"
|
||||
(ngModelChange)="updateGlobalInput($index, { multiple: $event })" />
|
||||
<span>Multiple</span>
|
||||
</label>
|
||||
|
||||
@if (!readOnly()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="title-toolbar-global-delete"
|
||||
matTooltip="Delete global input"
|
||||
(click)="removeGlobalInput($index)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (globalInputValidationErrors()[$index]; as errorMessage) {
|
||||
<div class="title-toolbar-global-error">{{ errorMessage }}</div>
|
||||
}
|
||||
<div class="title-toolbar-global-syntax">
|
||||
Available references: <code>{{ globalTemplateReference(globalInput.name) }}</code> and <code>{{ globalSpelReference(globalInput.name) }}</code>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (creatingGlobalInput()) {
|
||||
<div class="title-toolbar-global-modal-backdrop" (click)="cancelNewGlobalInput()"></div>
|
||||
<div class="title-toolbar-global-modal" role="dialog" aria-modal="true" aria-labelledby="global-input-modal-title">
|
||||
<div class="title-toolbar-global-modal-title" id="global-input-modal-title">Add Global Input</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="title-toolbar-global-modal-field">
|
||||
<mat-label>Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[ngModel]="draftGlobalInput().name"
|
||||
(ngModelChange)="updateDraftGlobalInput({ name: $event })"
|
||||
placeholder="sharedTopic" />
|
||||
</mat-form-field>
|
||||
|
||||
<label class="title-toolbar-global-select title-toolbar-global-modal-select">
|
||||
<span>Type</span>
|
||||
<select
|
||||
[ngModel]="draftGlobalInput().type"
|
||||
(ngModelChange)="updateDraftGlobalInput({ type: $event })">
|
||||
<option value="TEXT">TEXT</option>
|
||||
<option value="FILE">FILE</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="title-toolbar-global-checkbox title-toolbar-global-modal-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="draftGlobalInput().multiple"
|
||||
(ngModelChange)="updateDraftGlobalInput({ multiple: $event })" />
|
||||
<span>Multiple</span>
|
||||
</label>
|
||||
|
||||
@if (!draftGlobalInput().name.trim()) {
|
||||
<div class="title-toolbar-global-error">Name is required</div>
|
||||
} @else if (!canSaveDraftGlobalInput()) {
|
||||
<div class="title-toolbar-global-error">Name must be unique</div>
|
||||
}
|
||||
|
||||
<div class="title-toolbar-global-modal-help">
|
||||
References: <code>{{ globalTemplateReference(draftGlobalInput().name) }}</code> and <code>{{ globalSpelReference(draftGlobalInput().name) }}</code>
|
||||
</div>
|
||||
|
||||
<div class="title-toolbar-global-modal-actions">
|
||||
<button type="button" mat-stroked-button (click)="cancelNewGlobalInput()">Cancel</button>
|
||||
<button type="button" mat-flat-button [disabled]="!canSaveDraftGlobalInput()" (click)="saveNewGlobalInput()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (snackbarMessage()) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
|
|
@ -8,6 +8,7 @@ import { MatInputModule } from '@angular/material/input';
|
|||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { FlowGlobalInput } from '@models/flow';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { FlowsService } from '@services/flows/flows';
|
||||
|
|
@ -20,6 +21,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
|
|||
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule, MatSlideToggleModule],
|
||||
templateUrl: './title-toolbar.html',
|
||||
styleUrl: './title-toolbar.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TitleToolbar {
|
||||
private snackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -46,7 +48,33 @@ export class TitleToolbar {
|
|||
|
||||
notSaved = computed(() => this.editorState.isDirty());
|
||||
blockSyncInProgress = this.blocksService.hasPendingServerSync;
|
||||
canSave = computed(() => !this.readOnly() && this.notSaved() && !this.blockSyncInProgress());
|
||||
canSave = computed(() =>
|
||||
!this.readOnly() &&
|
||||
this.notSaved() &&
|
||||
!this.blockSyncInProgress() &&
|
||||
!this.hasGlobalInputValidationIssues()
|
||||
);
|
||||
globalInputs = computed(() => this.flow()?.data.globalInputs ?? []);
|
||||
hasGlobalInputValidationIssues = computed(() =>
|
||||
this.globalInputValidationErrors().some((message) => !!message)
|
||||
);
|
||||
globalInputValidationErrors = computed(() => {
|
||||
const inputs = this.globalInputs();
|
||||
const nameCounts = new Map<string, number>();
|
||||
|
||||
for (const input of inputs) {
|
||||
const normalized = String(input.name ?? '').trim().toLowerCase();
|
||||
if (!normalized) continue;
|
||||
nameCounts.set(normalized, (nameCounts.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return inputs.map((input) => {
|
||||
const name = String(input.name ?? '').trim();
|
||||
if (!name) return 'Name is required';
|
||||
if ((nameCounts.get(name.toLowerCase()) ?? 0) > 1) return 'Name must be unique';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
canTogglePublished = computed(() => !!this.flow() && this.isOwner());
|
||||
canFinalize = computed(() => !!this.flow() && this.isOwner() && !this.flow()!.finalized);
|
||||
canExecute = computed(() => {
|
||||
|
|
@ -60,6 +88,9 @@ export class TitleToolbar {
|
|||
snackbarType = signal<'success' | 'error'>('success');
|
||||
editingTitle = signal(false);
|
||||
draftTitle = signal('');
|
||||
globalInputsOpen = signal(false);
|
||||
creatingGlobalInput = signal(false);
|
||||
draftGlobalInput = signal<FlowGlobalInput>({ name: '', type: 'TEXT', multiple: false });
|
||||
|
||||
startEditingTitle() {
|
||||
const flow = this.flow();
|
||||
|
|
@ -96,6 +127,96 @@ export class TitleToolbar {
|
|||
this.editingTitle.set(false);
|
||||
}
|
||||
|
||||
addGlobalInput() {
|
||||
if (this.readOnly()) return;
|
||||
this.draftGlobalInput.set({ name: '', type: 'TEXT', multiple: false });
|
||||
this.creatingGlobalInput.set(true);
|
||||
}
|
||||
|
||||
saveNewGlobalInput() {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
||||
const draft = this.draftGlobalInput();
|
||||
const name = draft.name.trim();
|
||||
if (!name) return;
|
||||
|
||||
const alreadyExists = (flow.data.globalInputs ?? []).some((input) => input.name.trim().toLowerCase() === name.toLowerCase());
|
||||
if (alreadyExists) return;
|
||||
|
||||
this.editorState.updateData({
|
||||
...flow.data,
|
||||
globalInputs: [
|
||||
...(flow.data.globalInputs ?? []),
|
||||
{ ...draft, name }
|
||||
]
|
||||
});
|
||||
this.creatingGlobalInput.set(false);
|
||||
this.globalInputsOpen.set(true);
|
||||
}
|
||||
|
||||
cancelNewGlobalInput() {
|
||||
this.creatingGlobalInput.set(false);
|
||||
}
|
||||
|
||||
updateDraftGlobalInput(patch: Partial<FlowGlobalInput>) {
|
||||
this.draftGlobalInput.update((current) => ({
|
||||
...current,
|
||||
...patch
|
||||
}));
|
||||
}
|
||||
|
||||
updateGlobalInput(index: number, patch: Partial<FlowGlobalInput>) {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
||||
const globalInputs = [...(flow.data.globalInputs ?? [])];
|
||||
if (!globalInputs[index]) return;
|
||||
globalInputs[index] = {
|
||||
...globalInputs[index],
|
||||
...patch
|
||||
};
|
||||
|
||||
this.editorState.updateData({
|
||||
...flow.data,
|
||||
globalInputs
|
||||
});
|
||||
}
|
||||
|
||||
removeGlobalInput(index: number) {
|
||||
const flow = this.flow();
|
||||
if (!flow || this.readOnly()) return;
|
||||
|
||||
const globalInputs = [...(flow.data.globalInputs ?? [])];
|
||||
globalInputs.splice(index, 1);
|
||||
this.editorState.updateData({
|
||||
...flow.data,
|
||||
globalInputs
|
||||
});
|
||||
}
|
||||
|
||||
globalTemplateReference(name: string): string {
|
||||
const resolved = name.trim() || 'name';
|
||||
return `\${{global.${resolved}}}`;
|
||||
}
|
||||
|
||||
globalSpelReference(name: string): string {
|
||||
const resolved = name.trim() || 'name';
|
||||
return `#global.${resolved}`;
|
||||
}
|
||||
|
||||
toggleGlobalInputs() {
|
||||
this.globalInputsOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
canSaveDraftGlobalInput(): boolean {
|
||||
const flow = this.flow();
|
||||
const draft = this.draftGlobalInput();
|
||||
const name = draft.name.trim();
|
||||
if (!flow || !name) return false;
|
||||
return !(flow.data.globalInputs ?? []).some((input) => input.name.trim().toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
save() {
|
||||
if (!this.canSave()) return;
|
||||
this.editorState.save().pipe(
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export class EditorStateHolder {
|
|||
this.currentFlow.set(doc);
|
||||
this.isDirty.set(false);
|
||||
this.validationRequiresSave.set(false);
|
||||
this.applyFlowValidationErrors(doc.validationErrors ?? []);
|
||||
this.applyFlowValidationErrors(doc.validationErrors ?? [], doc);
|
||||
this.ensureValidationForFlow(doc);
|
||||
this.clearBlockSelection();
|
||||
return true;
|
||||
|
|
@ -70,7 +70,7 @@ export class EditorStateHolder {
|
|||
this.currentFlow.set(null);
|
||||
this.isDirty.set(false);
|
||||
this.validationRequiresSave.set(false);
|
||||
this.applyFlowValidationErrors([]);
|
||||
this.applyFlowValidationErrors([], null);
|
||||
this.lastValidationFetchKey = null;
|
||||
this.clearBlockSelection();
|
||||
}
|
||||
|
|
@ -80,7 +80,7 @@ export class EditorStateHolder {
|
|||
this.currentFlow.set(flow);
|
||||
this.isDirty.set(options?.markDirty === true);
|
||||
this.validationRequiresSave.set(options?.markDirty === true);
|
||||
this.applyFlowValidationErrors(flow.validationErrors ?? []);
|
||||
this.applyFlowValidationErrors(flow.validationErrors ?? [], flow);
|
||||
this.ensureValidationForFlow(flow);
|
||||
this.clearBlockSelection();
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@ export class EditorStateHolder {
|
|||
const nextFlow = { ...current, data };
|
||||
this.currentFlow.set(nextFlow);
|
||||
this.markDirty();
|
||||
this.applyFlowValidationErrors(current.validationErrors ?? [], nextFlow);
|
||||
if (options?.structural !== false) {
|
||||
this.validationRequiresSave.set(true);
|
||||
}
|
||||
|
|
@ -170,7 +171,7 @@ export class EditorStateHolder {
|
|||
};
|
||||
this.currentFlow.set(nextFlow);
|
||||
this.lastValidationFetchKey = this.validationFetchKey(nextFlow);
|
||||
this.applyFlowValidationErrors(validationErrors);
|
||||
this.applyFlowValidationErrors(validationErrors, nextFlow);
|
||||
this.markSaved();
|
||||
this.validationRequiresSave.set(false);
|
||||
}),
|
||||
|
|
@ -181,7 +182,7 @@ export class EditorStateHolder {
|
|||
);
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.applyFlowValidationErrors(this.extractValidationErrors(error));
|
||||
this.applyFlowValidationErrors(this.extractValidationErrors(error), this.currentFlow());
|
||||
return throwError(() => error);
|
||||
})
|
||||
)
|
||||
|
|
@ -201,11 +202,13 @@ export class EditorStateHolder {
|
|||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
private applyFlowValidationErrors(errors: FlowValidationError[]) {
|
||||
private applyFlowValidationErrors(errors: FlowValidationError[], flow?: Flow | null) {
|
||||
const normalized = Array.isArray(errors) ? errors : [];
|
||||
this.flowValidationErrors.set(normalized);
|
||||
const derived = flow ? this.deriveGlobalInputReferenceErrors(flow) : [];
|
||||
const merged = [...normalized, ...derived];
|
||||
this.flowValidationErrors.set(merged);
|
||||
this.highlightedValidationNodeIds.set(Array.from(new Set(
|
||||
normalized.flatMap((error) => Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : [])
|
||||
merged.flatMap((error) => Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : [])
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +226,7 @@ export class EditorStateHolder {
|
|||
if (!flow) return;
|
||||
if (flow.status === 'EXECUTABLE') {
|
||||
this.lastValidationFetchKey = this.validationFetchKey(flow);
|
||||
this.applyFlowValidationErrors(flow.validationErrors ?? [], flow);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +242,10 @@ export class EditorStateHolder {
|
|||
...current,
|
||||
validationErrors
|
||||
});
|
||||
this.applyFlowValidationErrors(validationErrors);
|
||||
this.applyFlowValidationErrors(validationErrors, {
|
||||
...current,
|
||||
validationErrors
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Retrieve flow validation failed', error);
|
||||
|
|
@ -249,4 +256,75 @@ export class EditorStateHolder {
|
|||
private validationFetchKey(flow: Flow): string {
|
||||
return `${flow.id}:${flow.status}:${flow.updatedAt?.toISOString?.() ?? ''}`;
|
||||
}
|
||||
|
||||
private deriveGlobalInputReferenceErrors(flow: Flow): FlowValidationError[] {
|
||||
const definedGlobals = new Set(
|
||||
(flow.data.globalInputs ?? [])
|
||||
.map((input) => String(input.name ?? '').trim())
|
||||
.filter((name) => name.length > 0)
|
||||
);
|
||||
const occurrences = new Map<string, Set<string>>();
|
||||
|
||||
const collectFromValue = (value: unknown, ownerNodeId: string) => {
|
||||
if (typeof value === 'string') {
|
||||
const names = extractReferencedGlobalNames(value);
|
||||
for (const name of names) {
|
||||
if (definedGlobals.has(name)) continue;
|
||||
if (!occurrences.has(name)) {
|
||||
occurrences.set(name, new Set<string>());
|
||||
}
|
||||
occurrences.get(name)!.add(ownerNodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) collectFromValue(item, ownerNodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
for (const item of Object.values(value as Record<string, unknown>)) {
|
||||
collectFromValue(item, ownerNodeId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scanNodeConfiguration = (nodeId: string, configuration: unknown) => {
|
||||
if (!configuration || typeof configuration !== 'object') return;
|
||||
collectFromValue(configuration, nodeId);
|
||||
};
|
||||
|
||||
for (const block of flow.data.blocks ?? []) {
|
||||
scanNodeConfiguration(block.id, block.specificConfiguration);
|
||||
}
|
||||
for (const container of flow.data.containers ?? []) {
|
||||
scanNodeConfiguration(container.id, container.specificConfiguration);
|
||||
}
|
||||
|
||||
return Array.from(occurrences.entries()).map(([name, nodeIds]) => ({
|
||||
code: 'GLOBAL_INPUT_NOT_DEFINED',
|
||||
entity: 'flow',
|
||||
field: 'globalInputs',
|
||||
id: flow.id,
|
||||
message: `Global input "${name}" is referenced but not defined. The flow can be saved, but it will remain DRAFT / not executable.`,
|
||||
relatedNodeIds: Array.from(nodeIds)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function extractReferencedGlobalNames(content: string): string[] {
|
||||
const names = new Set<string>();
|
||||
const templateRegex = /\$\{\{\s*global\.([A-Za-z0-9_]+)\s*\}\}/g;
|
||||
const spelRegex = /#global\.([A-Za-z0-9_]+)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = templateRegex.exec(content)) !== null) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
while ((match = spelRegex.exec(content)) !== null) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(names);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
areFlowValueKindsCompatible,
|
||||
FlowBlock,
|
||||
FlowData,
|
||||
FlowGlobalInput,
|
||||
FLOW_DEPENDANT_PORT_KEY,
|
||||
FLOW_DEPENDENCY_PORT_KEY,
|
||||
FLOW_DEPENDENCY_SOCKET_TYPE,
|
||||
|
|
@ -44,6 +45,7 @@ type ReteRuntimeContext = {
|
|||
containersService: ContainersService;
|
||||
flowState: EditorStateHolder;
|
||||
readonly: boolean;
|
||||
globalInputs: FlowGlobalInput[];
|
||||
};
|
||||
|
||||
export async function createEditor(
|
||||
|
|
@ -65,7 +67,8 @@ export async function createEditor(
|
|||
blocksService: injector.get(BlocksService),
|
||||
containersService: injector.get(ContainersService),
|
||||
flowState: injector.get(EditorStateHolder),
|
||||
readonly
|
||||
readonly,
|
||||
globalInputs: cloneValue(flowData.globalInputs ?? [])
|
||||
};
|
||||
editorRuntime.set(editor, runtime);
|
||||
|
||||
|
|
@ -144,6 +147,7 @@ export async function createEditor(
|
|||
}
|
||||
|
||||
export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
||||
const runtime = editorRuntime.get(editor);
|
||||
const nodeIdToBlockId = new Map<string, string>();
|
||||
const nodes: FlowNode[] = editor.getNodes().map((node) => {
|
||||
const blockData = node.data;
|
||||
|
|
@ -182,10 +186,17 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
.map(({ kind, ...connection }) => connection),
|
||||
dependencies: allConnections
|
||||
.filter((connection) => connection.kind === 'dependency')
|
||||
.map(({ sourceId, targetId }) => ({ sourceId, targetId }))
|
||||
.map(({ sourceId, targetId }) => ({ sourceId, targetId })),
|
||||
globalInputs: cloneValue(runtime?.globalInputs ?? [])
|
||||
};
|
||||
}
|
||||
|
||||
export function setEditorGlobalInputs(editor: NodeEditor<HFSchemes>, globalInputs: FlowGlobalInput[]) {
|
||||
const runtime = editorRuntime.get(editor);
|
||||
if (!runtime) return;
|
||||
runtime.globalInputs = cloneValue(globalInputs ?? []);
|
||||
}
|
||||
|
||||
export async function addBlockToEditor(
|
||||
editor: NodeEditor<HFSchemes>,
|
||||
area: AreaPlugin<HFSchemes, AreaExtra>,
|
||||
|
|
|
|||
Loading…
Reference in New Issue