@esfaenza/flow-builder
v20.3.21
Published
Editor visuale di flow: componente standalone Angular, con tutto il traffico verso il backend dietro un'unica interfaccia.
Downloads
2,285
Readme
flow-builder
Editor visuale per i flow descritti in FRONTEND.md: un componente
standalone Angular che disegna il grafo, edita ogni tipo di elemento, valida, versiona e
prova un flow.
Requisiti: Angular ≥ 18 (sviluppato e verificato su 19.2), TypeScript.
Indice
- Integrazione in tre passi
- L'interfaccia verso il backend
- Cosa fa il builder
- Architettura
- Dipendenze esterne
- Tema e personalizzazione
- Le trappole della §13, una per una
- Limiti noti
Integrazione in tre passi
1. Fornire l'implementazione dell'interfaccia
FlowBuilderApi e' l'unica porta verso il backend: tutto il traffico di dati passa da
lì, e nessun componente della libreria conosce HTTP.
import { FlowBuilderApi, HttpFlowBuilderApi, FLOW_BUILDER_HTTP_CONFIG } from 'flow-builder';
bootstrapApplication(App, {
providers: [
provideHttpClient(),
{ provide: FLOW_BUILDER_HTTP_CONFIG, useValue: { baseUrl: '/api' } },
{ provide: FlowBuilderApi, useClass: HttpFlowBuilderApi },
],
});HttpFlowBuilderApi usa le rotte proposte dalla §6. Se le vostre differiscono, ci sono
due strade e nessuna delle due tocca l'editor:
// 1. estendere e sovrascrivere i soli metodi con rotta diversa
class NostraApi extends HttpFlowBuilderApi {
override loadFlow(flowName: string, version?: number) { /* … */ }
}
// 2. implementare `FlowBuilderApi` da zero: e' una classe astratta, quindi il compilatore
// elenca esattamente i metodi che mancanoI metodi con implementazione di default rifiutano con MissingService: un ambiente che non
espone parseFlow, exportFlow o le primitive di esecuzione resta usabile, con il comando
corrispondente degradato invece di rotto.
2. Includere i due fogli di stile
// angular.json → architect.build.options.styles
"styles": [
"node_modules/@foblex/flow/styles/default.scss",
"node_modules/flow-builder/styles/flow-builder.css",
"src/styles.css"
]Servono entrambi. flow-builder.css contiene le variabili del tema, i controlli di form e
gli stili degli archi: i path SVG delle connessioni sono creati a runtime e non portano
l'attributo di scope di Angular, quindi un CSS incapsulato non li raggiunge.
3. Montare il componente
<fb-flow-builder
[flowName]="'ApprovazioneOrdine'"
[version]="null"
[author]="utenteCorrente"
(saved)="onSaved($event)"
(activated)="onActivated($event)"
/>| Input | Significato |
|---|---|
| flowName | il flow da aprire; null → si parte da un flow nuovo |
| version | la versione da aprire; assente → l'attiva se c'e', altrimenti l'ultima (§6.1) |
| author | registrato sulla versione e mostrato nell'elenco |
| defaultProcessType | processType iniziale di un flow nuovo |
| inspectorMode | 'dialog' (predefinito) apre il dettaglio dell'elemento in una finestra sopra il canvas; 'panel' lo tiene nel pannello laterale |
| Output | Quando |
|---|---|
| saved | dopo un salvataggio riuscito, con FlowSaveResult. Lo emette anche «Duplica», e in quel caso flowName e' quello della copia: la sessione si e' spostata su di essa, quindi l'host che tiene il nome da parte deve adottarlo |
| activated | dopo un'attivazione riuscita |
Il componente vuole un'altezza: <fb-flow-builder style="height: 100vh">, oppure un
contenitore flex. Gli store sono provider del componente, quindi due builder sulla stessa
pagina editano due flow indipendenti.
L'interfaccia verso il backend
Metodi raggruppati come nella specifica. Gli astratti sono obbligatori; i concreti
sono opzionali e rifiutano con MissingService se non sovrascritti.
| §6.1 Lettura | |
|---|---|
| listFlows(query?) | astratto |
| listVersions(flowName) | astratto |
| loadFlow(flowName, version?) | astratto |
| exportFlow(flowName, query?) | opzionale |
| parseFlow(definition) | opzionale — serve all'import di un file |
| §6.2 Scrittura | |
|---|---|
| createFlow(request) | astratto |
| saveFlow(flowName, request) | astratto |
| createVersion(flowName, request?) | astratto |
| activateVersion(flowName, version) | astratto |
| deactivateFlow(flowName) | astratto |
| cloneFlow, deleteVersion, deleteFlow | opzionali — senza cloneFlow «Duplica» resta disponibile e ripiega su createFlow |
| §6.3 Validazione | |
|---|---|
| validateDefinition(definition) | astratto — e' il pannello dei problemi |
| validateVersion(flowName, version) | opzionale |
| validateFormula(request) | opzionale — verifica una espressione mentre si digita, ed e' cio' che alimenta <fb-formula-editor>. Unica primitiva di analisi con un involucro: il documento sta sotto flow, insieme a expression, usage e expectedDataType (§6.3, §6.4). I rilievi portano position/length, che la validazione dell'intero documento non ha. Tre esiti da tenere distinti e nessuno dei tre e' «formula valida»: rifiuto MissingService (primitiva assente), isVerified: false (primitiva sì, motore non configurato), isValid (verificata davvero) |
| §6.4 Dizionari e cataloghi | |
|---|---|
| getDictionaries(processType?) | astratto — processType filtra le globali (§4.1) |
| getReferences(definition) / getWritableReferences(definition) | astratti — prendono il documento nudo e restituiscono tutti i riferimenti: nel body non c'e' nessun filtro di tipo, e la compatibilita' col campo la decide l'editor (§6.4, §13.17) |
| getConnectorTargets(definition, excluding?) | astratto |
| getOutline(definition) | astratto |
| listObjects(), listFields(object, usage?) | astratti |
| listActionTypes / listActions / listActionParameters | astratti |
| listScripts / listScriptParameters | astratti |
| listForms(kind?) / listFormParameters | astratti — un catalogo, due ruoli (§5.1, §5.2): le schermate intere di uno Screen (formName) e i componenti di uno screen dinamico (extensionName) stanno nello stesso elenco e si distinguono per formKind. kind vale Form, Component o assente per tutto; una voce senza formKind vale Any e resta proponibile in entrambi i posti, perche' un catalogo che il ruolo non lo dichiara non deve vietare tutto. Usare l'uno al posto dell'altro e' FORM_KIND_MISMATCH, che e' un errore diverso da «il nome non esiste». I parametri si chiedono per nome, uguali per i due ruoli |
| listEnumTypes, listEvents, listSubflowCandidates | astratti |
| describeObject, listFieldValues | opzionali |
| listEnumValues(enumType) | opzionale — i valori di un tipo di enumerazione, cioe' i nomi scrivibili in enumValue (§4.2, §4.6). listEnumTypes e' l'altra meta' e non basta: quella dice quali tipi esistono, questa quali valori ha un tipo. enumType e' esattamente l'objectType della risorsa, del parametro o del membro di una classe (§4.7.1): la risposta e' del tipo, non del punto di uso, e la cache e' per nome del tipo. Lista vuota = "non lo so" — il valore si scrive a mano, e un tipo sconosciuto risponde vuoto, non 404 |
| listStructures, describeStructure(className) | opzionali — le classi e i loro membri per le risorse Structure (§4.7). listStructures e' l'elenco delle classi senza membri; describeStructure restituisce la descrizione di una classe (className, label, members), non un array nudo (§4.7.1). Senza, le classi non sono verificabili e il nome si scrive a mano: "non lo so", non "non esiste". I tre esiti sono distinti: members popolato = elenco autorevole, members: [] = membri non dichiarati (si digita, nessuna accusa), rifiuto StructureNotFound = classe non registrata (si segnala la classe) |
| §6.5 Esecuzione | |
|---|---|
| startInterview, respondToScreen, resumeInterview, inspectInterview, abandonInterview | opzionali — senza, il pannello «Prova» segnala che la funzione non c'e' |
| completeStageStep(request) | opzionale — conclude uno step di orchestrazione (§5.14) |
Errori. Ogni metodo, in caso di rifiuto, deve fallire con un FlowApiError
categorizzato (§10). L'editor si comporta in base alla category, mai leggendo il
messaggio: NotEditable fa comparire «Nuova versione» al posto di «Salva»,
VersionConflict apre il banner con «ricarica» / «salva come nuova versione»,
ValidationFailed apre il pannello dei problemi con il payload.
throw new FlowApiError('VersionConflict', 'Ha salvato mrossi.', undefined, 'mrossi');Un'implementazione completa e commentata sta in
projects/demo/src/app/backend/in-memory-flow-builder-api.ts:
copre tutte le primitive, il ciclo di vita, la concorrenza ottimistica e un esecutore finto,
senza una sola richiesta di rete.
Cosa fa il builder
Canvas. Node draggabili con l'ingresso sul bordo superiore e un'uscita per ramo sul
bordo inferiore, in fila e nell'ordine del modello, secondo la mappa della §3.5:
connector, faultConnector, timeoutConnector,
rules[].connector, nextValueConnector / noMoreValuesConnector,
waitEvents[].connector, scheduledPaths[].connector. Archi colorati per kind (Fault
rosso, Rule viola ed etichettato, Default tratteggiato, LoopNext verde), isGoTo reso come
salto, pan/zoom, minimap, selezione, creazione e riassegnazione di archi trascinando,
drag-and-drop dalla palette, comando «Riordina» che ricalcola e scrive le coordinate nel
documento. I node non raggiungibili e i rami dichiarati senza destinazione sono marcati
sul canvas.
Il lato da cui gli archi entrano ed escono e' dichiarato (fConnectorConnectableSide:
TOP sull'ingresso, BOTTOM sulle uscite), non calcolato: lasciandolo calcolare a
@foblex/flow, un ramo che risale — il ritorno di un Loop, per esempio — uscirebbe di fianco e
il grafo perderebbe la lettura dall'alto verso il basso. La larghezza del node cresce a
scaglioni col numero di rami, perche' le etichette delle uscite stanno in fila: arriva da una
classe (.fb-node--outlets-N) e non da uno stile inline, perche' @foblex/flow riscrive
l'attributo style del node a ogni spostamento e uno [style.width] verrebbe cancellato. La
tabella delle larghezze e' la stessa che il comando «Riordina» passa a dagre: se divergessero,
i node larghi resterebbero sovrapposti.
Copia, incolla, duplica. Ctrl+C copia gli elementi selezionati — la selezione del canvas
puo' contenerne piu' d'uno (rettangolo, Ctrl+A) — Ctrl+V li incolla, Ctrl+D li duplica in
loco; gli stessi comandi stanno nella top bar («⧉» e «⎘») e sul node selezionato («⧉»). I tasti
ascoltano sull'host del builder e si ignorano quando il fuoco e' in un campo di testo, dove
Ctrl+C deve copiare il testo.
Cosa viaggia con il blocco: la configurazione intera dell'elemento, i collegamenti interni al
blocco (quelli verso l'esterno no — sarebbero CONNECTOR_TARGET_UNKNOWN) e le risorse
referenziate, che e' cio' che rende il gesto utile su un elemento complesso. Gli appunti stanno in
localStorage, quindi valgono anche fra flow diversi e fra schede: non e' la clipboard di sistema,
che per essere letta chiede un permesso che in un iframe puo' non arrivare mai.
L'incolla passa da una conferma perche' non e' mai una copia identica, e le tre cose che cambiano le decide il contratto:
- i nomi già usati vengono rinominati, e non solo quello dell'elemento: uno spazio di nomi unico
(§3.3) comprende gli step di orchestrazione, i campi di uno screen dinamico e le screen action, e
duplicare uno screen dinamico senza rinominare i campi produceva un
NAME_DUPLICATEDper campo; - i riferimenti interni al blocco seguono i nomi nuovi (§13.8: nessuna primitiva lo fa), condizioni,
visibilityRule, trigger e output automatici compresi; - le risorse che nella destinazione non esistono si creano (con una casella per non farlo), quelle
omonime si riusano, e cio' che resta irrisolvibile — un elemento rimasto nel flow di origine —
viene elencato invece di sparire in un
REFERENCE_UNKNOWNda scoprire dopo.
Tutto l'incolla e' un solo passo di annulla.
Dettaglio dell'elemento. Con inspectorMode="dialog" (predefinito) il form si apre in una
finestra sopra il canvas: doppio click sul node, bottone «✎» sul node, comando «Dettaglio»
nella top bar, click su un rilievo nel pannello dei problemi, o creazione di un elemento dalla
palette. Il click singolo seleziona soltanto: un click capita anche solo per spostare un
node. La dialog non ha «Annulla» — le modifiche entrano nel documento mentre si digita,
esattamente come nel pannello, e tornare indietro e' compito dell'annulla dell'editor. Vive
dentro il componente e non in fondo al body: la libreria e' innestabile in una pagina
qualsiasi.
Palette. Costruita da elementTypes: nessun tipo e' cablato, e i due non supportati
(Step, Experiment) sono nascosti tramite isSupported. L'etichetta del ramo di fault arriva
dalla mappa delle uscite invece di essere «ramo di errore» per tutti: su uno stage di
orchestrazione quel ramo e' lo step rifiutato, non un guasto.
La stessa mappa distingue i rami di recupero da errore (isFaultRecovery: Record*,
ActionCall, ScriptCall, Wait) dagli altri, perche' lasciarne uno vuoto non significa «il
percorso finisce qui» ma «se questo fallisce l'interview termina Failed e le modifiche
vengono annullate» (§3.5). L'editor delle uscite lo dice come promemoria — non col colore
d'avviso, perche' rinunciare al recupero e' la scelta piu' comune — e il backend lo segnala
con FAULT_CONNECTOR_MISSING, che e' un Warning e non blocca niente. Lo stage di
orchestrazione ne resta fuori: la §7 non lo comprende.
Un tipo con un discriminatore compare come piu' voci, una per valore: il Collection
Processor e' «Ordina» e «Filtra», non «ordina o filtra». Sono due gesti diversi — un Sort
vuole sortOptions, un Filter vuole condizioni (§5.6) — e sceglierli dalla palette evita di
sceglierli dopo, dentro il form. L'elemento nasce con il discriminatore già scritto, con la
label della variante (da cui viene anche il nome tecnico) e con il glifo della variante, lo
stesso che avra' sul canvas. core/element-variants.ts tiene la mappa tipo → campo del
discriminatore; i valori restano nel dizionario del backend (collectionProcessorTypes), e
senza quel dizionario il tipo torna a essere una voce sola.
Inspector. Un form per ogni tipo supportato: Start, Screen, Screen dinamico, Assignment, Decision, Loop, Collection Processor, Get / Create / Update / Delete / Rollback Records, Action, Script, Subflow, Wait, Custom Error, Transform, Orchestrated Stage. Più l'intestazione comune con la rinomina, che riscrive tutti i riferimenti all'elemento.
Screen dinamico (§5.2). È un compositore, non un form, e per questo la dialog e' larga il
doppio di com'era: tre colonne. A sinistra la palette (due schede — i componenti, e i campi di
un'entita' che portano tipo ed etichetta dallo schema), al centro la schermata disegnata (si
clicca il campo da configurare, lo si trascina dove deve stare, la larghezza in dodicesimi si
vede), a destra le proprieta' di cio' che e' selezionato — o della schermata, se non c'e'
selezione, che e' anche cio' che si ottiene cliccando l'anteprima fuori dai campi. L'anteprima e'
inerte di proposito: i controlli non ricevono il puntatore, perche' un input che accetta testo in
un editor di metadati fa credere di star compilando la schermata. Quali proprieta' mostrare lo
decide il dizionario
(screenFieldTypes e i suoi flag storesValue, isCollection, acceptsChoices, isContainer,
requiresDataType), non il codice: sono gli stessi flag che applica il runtime, e
reimplementarli porta a proporre configurazioni che il motore rifiuta.
Tre cose che il modello non mostra e il form dice:
- un campo che raccoglie un valore e' una risorsa del flow, referenziabile per nome ovunque
e di sola lettura — il suo nome vive nello spazio dei nomi comune, e rinominarlo riscrive i
riferimenti (
FlowDocumentStore.renameScreenField, come per i node: nessuna primitiva del backend lo fa); - le regole di visibilita' si rivalutano sui valori appena inviati, i campi nascosti vengono azzerati e solo i visibili vengono validati;
choiceReferencesaccetta soloChoiceeDynamicChoiceSet, e gli output automatici di unComponentInstancesono esclusivi con i suoioutputParameters.
Il riordino e l'annidamento si fanno trascinando (@angular/cdk/drag-drop, senza drop list: il
bersaglio si calcola dal DOM) oppure con i comandi «su / giu' / porta fuori».
Screen action (§5.2). Un campo che l'utente compila puo' innescare un'action i cui risultati
finiscono negli altri campi della stessa schermata: e' il caso «scrivi il codice fiscale e nome e
cognome compaiono da soli», e senza di essa lo si farebbe spezzando lo screen in due con un elemento
Action in mezzo. Si modellano nelle proprieta' della schermata (cliccare l'anteprima fuori dai
campi), in due elenchi che il contratto tiene separati — actions e triggers — perche' piu'
trigger possono invocare la stessa action. Identificazione e parametri sono quelli di un elemento
Action, ACTION_UNKNOWN e PARAMETER_UNKNOWN compresi.
Le tre cose che riguardano l'editor, e non il runtime:
- il picker della destinazione e' allargato qui e solo qui. L'
assignToReferencedi un output accetta anche i campi di questa schermata, che ovunque altrove sono di sola lettura:POST /flows/references/writablecontinua a escluderli — e giustamente, un Assignment che ci scrive restaTARGET_NOT_WRITABLE. L'allargamento e' quindi un parametro del chiamante (<fb-reference-picker [extraReferences]>), non un comportamento del picker; - il nome dell'action sta nello spazio dei nomi del flow (§3.3): con l'output automatico e' la
radice di
Cerca.nome, quindi un'omonimia con una variabile — o con unaStructurechiamata come lei — renderebbe ambiguo quel riferimento.usedNameslo comprende, e il form lo dice conNAME_DUPLICATEDcome per il nome di uno step; - un'action senza output e' un avviso (
SCREEN_ACTION_WITHOUT_OUTPUTS): viene invocata e il suo risultato si perde. L'output automatico non e' la cura per tutti i casi — rende il risultato leggibile comeCerca.nomema non scrive in nessun campo: e' la forma per una condizione, non per precompilare; - un trigger senza campo e senza
initBehaviornon scattera' mai (SCREEN_TRIGGER_WITHOUT_CAUSE), e l'unicoinitBehaviorammesso e'runOnLoad— ilrunOnRevisitdella specifica Salesforce e'SCREEN_TRIGGER_INIT_BEHAVIOR_UNKNOWN, e vale la pena saperlo perche' chi arriva da quella specifica lo scrive per abitudine.
Le action si aprono una alla volta: i cataloghi delle action e dei loro parametri dipendono da
actionType/actionName, e tenerne N in volo sarebbe N volte la corsa fra risposte che l'inspector
dell'Action risolve scartando quelle superate.
Sullo stage di orchestrazione il form dice le tre cose che il modello non mostra: gli step
non sono una sequenza (le frecce riordinano l'esame, non l'esecuzione), ingresso e uscita
non sono simmetriche (ingresso falso = step saltato, uscita falsa = stage in stallo), e
faultConnector e' il ramo dello step rifiutato, non un ramo di guasto — senza, un rifiuto
fa fallire l'interview. Una condizione che referenzia l'output di un altro step viene segnalata
con la via d'uscita: scrivere quel risultato in una variabile.
Risorse. I sette tipi, con i vincoli visibili: dataType obbligatorio, objectType per
Object / Enum / Structure, scale solo sui numerici, costanti che non possono referenziare
risorse, stageOrder distinti.
Classi (Structure). L'ottavo tipo di dato non e' un doppione di Object (§4.7): di
un'istanza di classe il flow legge e scrive i membri. Cambia quindi il form: la classe si
scoglie con <fb-structure-picker> e la sua assenza e' un errore — senza classe il runtime
non ha nulla da istanziare — mentre il campo «valore iniziale» non c'e' affatto, perche' non
esiste un structureValue e la variabile parte con un'istanza vuota. I membri si scelgono con
<fb-structure-member-picker>, e nelle destinazioni si propongono solo quelli scrivibili: un
membro calcolato si legge in una condizione ma assegnarlo e' TARGET_NOT_WRITABLE. Lo stesso
vale dentro <fb-reference-picker>, dove Richiesta.Ragione si propone e si verifica
(STRUCTURE_MEMBER_UNKNOWN). Un Transform con target Structure compone l'istanza intera in un
elemento solo: lì outputFieldApiName e' il nome di un membro, e il form lo dice invece di far
indovinare (§5.13).
Percorsi annidati. I percorsi con il punto non sono piu' un buco nero: la validazione li segue,
e quindi li segue l'editor. core/path-navigation.ts naviga la catena una tappa alla volta —
objectType di un membro Structure porta la classe annidata, quello di un membro Object porta
un'entita' dello schema, e da lì si continua con i campi (§4.7.1) — ed e' lo stesso codice per
i membri di una classe e per i percorsi di relazione di un record (Cliente.Citta, §4.4). Tre
conseguenze visibili: i pickers propongono il livello in cui si sta scrivendo e hanno un «›»
per scendere di uno; un refuso in mezzo (Richiesta.Clente.Nome) e' segnalato subito, con l'elenco
di cio' che esiste in quella tappa; e dove la catena si interrompe — un membro senza
objectType, una classe annidata non descritta, una collection — il rilievo e' l'avviso
PATH_NOT_VERIFIABLE con il motivo, che vuol dire "non lo so" e non impedisce di scrivere.
Restano fuori i percorsi radicati sul risultato automatico di un elemento
(Leggi_Contatti.Owner.Name): la validazione non li verifica, e l'editor non accusa cio' che il
backend non verifica.
Il filtro di tipo di un campo (§6.4) lo applica l'editor: la primitiva prende il documento nudo
e restituisce tutti i riferimenti, così la stessa risposta serve tutte le tendine di una schermata
(§13.17). La regola sta in core/reference-filter.util.ts, ed e' la stessa per le radici e per i
segmenti di un percorso. Il filtro guarda il tipo della radice, e quindi non basta: in un
parametro String una variabile Structure non e' compatibile, ma Richiesta.Ragione sì. Per
questo <fb-reference-picker> tiene l'elenco completo accanto a quello filtrato, e ne ricava
due cose: i contenitori compaiono come righe in cui si entra — cliccarle apre il percorso
invece di scegliere un valore del tipo sbagliato — e le radici si riconoscono tutte, così un
Leggi_Ordine.Numero già scritto in un campo String non viene accusato di non esistere. Nelle
proposte non entra comunque nessun valore del tipo sbagliato: i segmenti li filtra la stessa
regola, tenendo solo quelli da cui si scende. In un campo di destinazione le due liste sono due
richieste — getWritableReferences per le proposte, getReferences per le radici — perche' la
scrivibilita' e' del percorso e non della radice: un $User.Anagrafica.Citta scritto a mano e'
TARGET_NOT_WRITABLE, non un nome che non esiste.
C'e' un posto in cui alle proposte si aggiunge qualcosa che la primitiva non restituisce, e
extraReferences esiste solo per quello: l'output di una screen action, che si assegna a un campo
della schermata che si sta modificando (§5.2). Non e' una scorciatoia per riferimenti che il backend
non conosce — quei campi restano esclusi da references/writable, e altrove assegnarli e'
TARGET_NOT_WRITABLE: e' l'unico punto in cui la regola del contratto e' diversa, e per questo
l'elenco lo passa il chiamante.
Le globali si navigano come le risorse. Un percorso dichiarato dall'host puo' portare un
contenitore, e allora objectType dice di che tipo: $User.Anagrafica.Citta si propone e si
verifica come Richiesta.Ragione. La regola da non sbagliare e' che vince il prefisso dichiarato
piu' lungo — non tutto cio' che segue lo scope — perche' altrimenti un percorso giusto viene
segnalato come rotto (§4.1). Dove il catalogo dichiara il contenitore ma non il tipo il rilievo e'
PATH_NOT_VERIFIABLE, e la correzione sta nel catalogo dell'host; navigare uno scalare
($User.Email.Dominio) e' invece GLOBAL_UNKNOWN. Sulle destinazioni non cambia niente da
sapere: le globali sono di sola lettura, membri compresi — le assegnabili sono $Flow.CurrentStage,
$Flow.ActiveStages e i campi di $Record — e il picker lo dice con TARGET_NOT_WRITABLE invece
di far sembrare il nome inesistente (§5.2).
Su una destinazione la colonna che decide non e' la stessa nei due mondi: un membro porta il proprio
isWritable, un campo no. Fuori da un Create o da un Update il contesto non c'e' — un Assignment su
Cliente.Codice non sa se quel record verra' creato o aggiornato — quindi lì basta isCreateable
oppure isUpdateable, e un campo che non e' ne' l'uno ne' l'altro e' TARGET_NOT_WRITABLE in
qualunque contesto (§5.8).
Dei tre esiti di describeStructure il picker mostra tre messaggi diversi, perche' portano a
gesti opposti: con l'elenco autorevole un nome fuori elenco e' un errore; con members: [] la
classe e' valida ma i membri non sono dichiarati e il nome si digita, senza accuse; con un
404 (StructureNotFound) e' la classe a essere sbagliata, e il messaggio manda a correggere
quella invece del membro. L'intestazione dell'elenco usa la label della classe, che e' la
ragione per cui la risposta ha un involucro invece di essere un array.
Oggetti e campi. Ogni punto che chiede un'entita' o un suo campo usa lo stesso controllo:
<fb-object-picker> e <fb-field-picker>, una casella con autocomplete che filtra il catalogo
mentre si scrive. Un <select> andava bene con dieci entita', non con trecento; e dove il
catalogo non arriva il controllo resta lo stesso, con un avviso, invece di degradare in un
campo di testo cieco. La scrittura libera e' voluta — il catalogo dell'host puo' essere incompleto —
ma un nome fuori catalogo viene segnalato subito, prima che il backend risponda OBJECT_UNKNOWN o
FIELD_UNKNOWN. I percorsi di relazione (Cliente.Citta) si navigano: l'elenco diventa quello
dell'oggetto referenziato e l'ultimo segmento si verifica con l'uso richiesto (§4.4). Il campo si chiede sempre con l'uso giusto (filterable, sortable,
updateable, createable), che decide sia l'elenco sia il testo dell'avviso: "non filtrabile",
"non aggiornabile" e "non valorizzabile alla creazione" sono errori diversi. In particolare
createable e updateable non sono sinonimi (§5.8): un Create propone i primi, un Update i
secondi, e il validatore usa il codice giusto per l'elemento (FIELD_NOT_CREATEABLE /
FIELD_NOT_UPDATEABLE). Per lo stesso motivo l'editor non da' per scontato che Id sia
filtrabile: su una chiave composta porta la forma canonica della chiave e non e' filtrabile,
quindi l'editor dice di filtrare per le colonne della chiave invece di far cercare un campo che
nell'elenco non c'e'. Restano <select> i dizionari chiusi — tipi di dato, operatori,
enumerazioni — dove non c'e' niente da scrivere a mano.
Valori di enum. Un Enum ha due meta' e servono entrambe: l'objectType dice quale tipo —
e quello resta un <select>, perche' l'elenco dei tipi e' un dizionario chiuso — mentre i valori
di quel tipo arrivano da listEnumValues(objectType) e sono cio' che finisce in enumValue (§4.2,
§4.6). Senza la seconda chiamata all'utente resta una casella di testo su un insieme chiuso, che e'
il modo piu' facile di scrivere un valore che il runtime non riconoscera'. Li propone
<fb-enum-value-picker>, che il <fb-value-editor> monta al posto della casella di testo appena il
tipo della destinazione e' Enum: vale quindi in una condizione, in un filtro, nel valore iniziale
di una risorsa, in un parametro di action o di form, in un Assignment e in una Map di un Transform.
Cio' che si scrive e' il nome: numericValue viene mostrato accanto al valore — aiuta a
riconoscerlo in dati legacy — ma un numero in enumValue sarebbe un altro tipo (§13.18). Elenco
vuoto resta "non lo so" — primitiva non esposta, tipo senza valori dichiarati, objectType ancora
da scegliere — e allora il valore si digita; con l'elenco popolato un valore fuori elenco e' un
avviso, perche' nessun codice della §7 lo blocca ed e' l'editor ad accorgersene prima del runtime.
Formule. L'espressione va al motore di regole e la sua sintassi resta del motore: la libreria
non ha nessun parser. Cio' che ha e' il controllo, validateFormula (POST /flows/validate-formula,
§6.3), chiesto con debounce mentre si digita: risponde lo stesso motore, e i suoi rilievi portano la
posizione del tratto sbagliato. Li mostra <fb-formula-editor>, che monta la casella e sotto
l'elenco dei rilievi, ognuno con un bottone che seleziona quel tratto nella casella — senza il
salto, position resterebbe un numero da contare a mano. Lo stesso componente offre i nomi
citabili come autocompletamento mentre si scrive un identificatore: la fonte e'
POST /flows/references, la stessa del reference picker, output automatici compresi; frecce per
scorrere, Invio o clic per inserire. Elenco non disponibile = nessun suggerimento e nessuna
accusa — la formula si scrive lo stesso. Lo usano tutti i punti in cui si scrive
un'espressione: la risorsa Formula, la modalita' Formula di una condizione, la filterFormula
di un filtro, la regola di validazione di un campo di screen dinamico e il formulaExpression
dentro un valore. A ogni punto corrispondono un usage e un expectedDataType — Boolean per
condizioni e validation rule, il tipo dichiarato per una risorsa — e mandarli e' cio' che permette
al motore di dire «qui serve un booleano» invece di accettare un'espressione che fallira' davanti
all'utente. Il documento si manda in lavorazione: una formula cita le variabili appena
aggiunte. Tre esiti restano distinti e nessuno dei tre e' «formula valida»: primitiva assente
(MissingService, silenzio), isVerified: false (motore non configurato: si dice che non e'
stata controllata), isValid (verificata davvero, ed e' l'unico caso con la spunta verde) — una
spunta su un'espressione mai controllata e' peggio di nessuna spunta.
Un campo a null e' assente. Il valore ha un campo per tipo e va valorizzato uno e un solo
campo (§4.2); la §2 dice che cio' che non c'e' si omette, ma un backend che serializza tutte le
proprieta' — System.Text.Json senza IgnoreNullValues lo fa di default — manda
{"enumValue": "InIstruttoria", "formulaExpression": null, …}: un solo valore e sette caselle piene
di niente. Riconoscere il campo con !== undefined faceva vincere il primo letto, cioe'
formulaExpression: l'editor apriva la formula (vuota) e il valore, pur presente nel documento,
spariva dalla vista — e il conteggio dei campi accusava VALUE_AMBIGUOUS. La regola sta in
core/flow-value.util.ts (isValued, valuedFieldOf, valuedFieldsOf) e la usano l'editor del
valore e quello delle condizioni: null e undefined significano la stessa cosa, mentre
booleanValue: false, numberValue: 0 e stringValue: '' restano valori.
Perche' l'editor conosca il tipo della destinazione serve saperlo, e i percorsi non fanno eccezione:
POST /flows/references elenca le radici e non i percorsi, quindi su Richiesta.Stato la
corrispondenza esatta del nome non trova niente. Il tipo si ricava allora navigando la catena —
core/reference-path innesta l'elenco dei riferimenti su core/path-navigation: trova il prefisso
dichiarato piu' lungo (§4.1), naviga il resto un segmento alla volta e prende il tipo dell'ultimo
segmento. Lo usano l'Assignment (su getWritableReferences), le condizioni e i filtri sui record —
dove la radice e' l'entita', quindi Cliente.Citta si risolve come il campo che e'. In un Transform
il tipo viene dal membro della classe di destinazione, che l'inspector già descrive.
Dove il tipo non si sa — catena interrotta, catalogo assente, segmento inesistente, percorso radicato sull'output automatico di un elemento (che il backend non verifica, §7) — il campo resta generico invece di indovinare: un elenco chiuso di valori sbagliati e' peggio di una casella di testo.
Lo stesso controllo, nella variante <fb-name-picker>, vale per ogni altro nome che il flow
prende da fuori: tipo di action e action, script, form, i nomi dei loro parametri di ingresso
e di uscita, il flow di uno step in background e dei suoi evaluation flow, il flow invocato da
un Subflow e le variabili di input e output del flow invocato. Qui l'elenco arriva da chi
ospita il picker — il catalogo già caricato per altri motivi, listSubflowCandidates, le
variabili della definizione caricata — perche' dipende dal contesto: le action dipendono dal
tipo scelto, i parametri dall'action, le variabili dal subflow.
Un elenco vuoto resta "non lo so": il campo si scrive a mano e non si accusa nessun nome. Con
l'elenco popolato, un nome che non c'e' e' segnalato, e con la gravita' giusta: un'action,
un form, uno script o un parametro inesistenti sono errori che bloccano l'attivazione
(ACTION_UNKNOWN, FORM_UNKNOWN, PARAMETER_UNKNOWN) e si mostrano in rosso; un flow senza
versione attiva e' un avviso. Prima questi campi erano <select> con un'opzione sintetica
«(non nel catalogo)» per non perdere il valore, oppure un datalist che accettava qualunque
cosa in silenzio: il primo non si poteva cercare, il secondo non diceva niente.
Problemi. Pannello con filtro per gravita', navigazione all'elemento con un clic, validazione su pausa di digitazione (debounce 400 ms) e l'avvertenza che l'assenza di rilievi non e' una garanzia di correttezza.
Versioni. Elenco con stato, «Nuova versione» al posto di «Salva» sulle versioni chiuse, attivazione bloccata finche' ci sono errori, eliminazione dietro una conferma esplicita che spiega il rischio per le esecuzioni sospese.
Duplica. Il flow salvato sotto un altro nome, alla versione 1 (§6.2): il comando chiede nome
tecnico e nome visibile della copia, e l'editor continua sulla copia — restare sull'originale
farebbe finire il salvataggio successivo nel flow sbagliato. Due strade, e la differenza conta:
su un documento pulito si usa cloneFlow, che copia lato backend; con modifiche non salvate la
copia si crea con createFlow mandando il documento in mano, perche' cloneFlow copierebbe la
versione salvata e le modifiche appena scritte spariscono senza dirlo. La stessa strada e' il
ripiego dove cloneFlow non c'e' (e' opzionale). Un nome già usato lo rifiuta il backend con
AlreadyExists, e il modulo resta aperto sul nome da correggere.
Prova. Interview con form generato dalle variabili di input, form generico per gli screen
(valori in ingresso più i campi da restituire), canGoBack / canFinish / canPause come
verita' runtime, traccia navigabile e tabella delle risorse — con l'avviso in chiaro che la
traccia riporta anche i dati personali. Su un'orchestrazione sospesa distingue le due attese
(isWaitingForEvent / isWaitingForStageStep) e consente di concludere uno step al posto
dell'assegnatario, rifiuto compreso: la risposta puo' portare una interviewKey nuova, e il
pannello sostituisce quella vecchia invece di conservarla.
Import/export. Export JSON indentato; import via POST /flows/parse, che normalizza il
documento o lo rifiuta con InvalidDefinition.
Architettura
src/lib/
model/ i tipi: documento Flow, dizionari, payload API, errori categorizzati
api/ FlowBuilderApi (astratta = token DI) + HttpFlowBuilderApi
core/
element-outlets.ts la mappa "tipo di elemento → uscite" (§3.5), in un posto solo
flow-document.store.ts il documento come fonte di verita': flatten, CRUD, undo/redo
flow-dictionary.store.ts cache dei dizionari e memoizzazione dei cataloghi
flow-validation.store.ts debounce, indicizzazione dei rilievi per elemento e campo
formula-validation.service.ts la verifica di una singola formula, e la memoria di
"questo ambiente la primitiva non ce l'ha"
flow-editor-session.ts versioni, salvataggio, concorrenza ottimistica
flow-layout.service.ts auto-layout con dagre
flow-clipboard.ts copia/incolla: l'elenco unico dei campi-riferimento, la riscrittura
dei nomi, il piano dell'incolla (che nomi cambiano, che risorse
mancano, cosa resta irrisolto)
flow-clipboard.service.ts gli appunti condivisi fra istanze e schede (localStorage)
flow-name.util.ts nomi: regexp, namespace unico, slug dalla label
condition-logic.util.ts riscrittura di conditionLogic su cancella / riordina
condition-types.util.ts quali tipi si confrontano, e i numeri in cultura invariante
stage-step.util.ts gli step di uno stage: nomi, output non condizionabili
screen-field.util.ts l'albero dei campi di uno screen dinamico: percorsi, nomi, mutazioni
ui/
flow-builder.component.ts il componente da montare
canvas/ palette/ inspector/ resources/ problems/ versions/ debug/ shared/
styles/
flow-builder.css variabili del tema, controlli di form, stili degli archiDue scelte di progetto che vale la pena conoscere.
Il documento e' la fonte di verita' unica. Canvas, inspector e pannelli sono funzioni del
FlowDefinition in memoria, e ogni gesto e' una mutazione del documento. Le primitive del
backend non vengono mai chiamate dallo store, così l'editing resta sincrono e reattivo anche
mentre una validazione e' in volo.
Local-first per il disegno, backend per la verita'. Archi e raggiungibilita' sono
calcolati anche in locale, perche' trascinare un arco deve riflettersi subito;
POST /flows/outline viene richiamato con debounce e, quando arriva, il suo isReachable
sovrascrive il calcolo locale. Chiedere l'outline a ogni movimento del mouse renderebbe il
canvas dipendente dalla latenza.
Dipendenze esterne
Oltre ad Angular:
| Pacchetto | Perche' |
|---|---|
| @foblex/flow (+ @foblex/platform, @foblex/mediator, @foblex/2d, @foblex/utils) | rendering del grafo, pan/zoom, gesti su node e connessioni. Usata in classic mode: la libreria disegna e riconosce i gesti, lo stato resta nostro |
| dagre | auto-layout gerarchico del comando «Riordina» |
| @angular/cdk | il solo drag-drop, per l'albero dei campi dello screen dinamico. Nessun componente Material: i controlli restano input nativi |
Nessun design system: i controlli sono input nativi con binding espliciti e CSS custom, così il builder si integra nel tema dell'app ospite senza imporre il proprio.
Tema e personalizzazione
Le variabili si sovrascrivono sul contenitore:
.mio-contenitore {
--fb-accent: #7048c4;
--fb-surface: #ffffff;
--fb-border: #dcdfe4;
--fb-error: #b3261e;
--fb-edge-fault: #b3261e;
}Elenco completo in styles/flow-builder.css. Per la variante scura basta
data-fb-theme="dark" su un antenato.
La dialog di modifica ha una misura sola per ogni tipo di elemento — l'inspector dentro
cambia, la finestra no, altrimenti i comandi si spostano sotto il mouse a ogni apertura — e le
due misure sono token: --fb-dialog-width (1400px) e --fb-dialog-height (860px), entrambe
limitate allo spazio disponibile. La larghezza e' quella che serve al compositore dello screen
dinamico, che e' a due pannelli.
Nota sugli archi: il colore si imposta valorizzando le custom properties del tema di
@foblex/flow (--ff-connection-color, --ff-marker-color) invece di sovrascrivere le sue
regole, perche' i selettori hanno la stessa specificita' e chi vince dipenderebbe dall'ordine
con cui l'app ospite carica i fogli di stile. Lo stesso vale per il resto del disegno che fa
la libreria: flow-builder.css mappa i propri colori sui token --ff-* (sfondo del canvas,
pattern, minimappa, spessore degli archi) e ne annulla due — le maniglie di riassegnazione
(--ff-connection-drag-handle-*), che disegnate formavano una macchia sopra la punta della
freccia. Il colore dell'uscita di un ramo passa da --ff-connector-connected-color, unico
modo di sopravvivere alla regola di foblex per i connettori collegati, che ha specificita'
(0,4,1) e non si batte con una classe.
L'aspetto dei node segue l'esempio call-center di @foblex/flow: card con badge dell'icona
colorato per categoria, titolo, sottotitolo e uscite sul bordo inferiore. I glifi sono
caratteri Unicode in core/element-icons.ts, condivisi fra palette e canvas: nessun asset da
distribuire e nessuna dipendenza da un font di icone dell'applicazione ospite.
Le trappole della §13, una per una
| # | Trappola | Dove e' gestita |
|---|---|---|
| 1 | Operatori unari: rightValue e' l'esito atteso, non il termine di confronto | ConditionEditorComponent: per gli operatori con isUnary il campo «confronta con» non esiste; c'e' un selettore «è / NON è» e la frase «questa condizione e' vera quando: Esito NON è vuoto». Il flag arriva dal dizionario, non da una lista cablata |
| 2 | assignNextValueToReference di un Collection Processor non e' la destinazione | CollectionProcessorInspectorComponent: il campo si chiama «Elemento in esame», e un callout dice che il risultato e' l'output automatico dell'elemento |
| 3 | Gli output automatici sono riferimenti validi anche se non dichiarati | ReferencePickerComponent chiede l'elenco a POST /flows/references, che li include; non ricostruisce nulla lato client |
| 4 | Cancellare una condizione richiede di riscrivere conditionLogic | condition-logic.util.ts: removeCondition e moveCondition rimappano gli indici 1-based; un termine rimasto orfano diventa ? e viene segnalato |
| 5 | Node e risorse condividono un unico spazio di nomi | FlowDocumentStore.usedNames unisce cinque insiemi — node, risorse, step di orchestrazione, campi di screen dinamico e screen action, cioe' tutto cio' il cui nome e' la radice di un riferimento (§3.3) — e checkFlowName confronta case-insensitive; pannello risorse e inspector usano lo stesso controllo |
| 6 | processType non e' decorativo | Banner in cima all'editor per AutoLaunched + screen, Orchestration senza stage, Screen senza screen; la palette segnala lo Screen incompatibile. Il processType viaggia anche a getDictionaries, perche' decide quali globali esistono |
| 7 | Le regole di una Decision sono ordinate | DecisionInspectorComponent: regole numerate, frecce di riordino, callout «valutate dall'alto verso il basso, si ferma alla prima vera» |
| 8 | Rinominare non aggiorna i riferimenti | FlowDocumentStore.renameNode cammina il documento e riscrive ogni campo di riferimento, incluso il caso navigato (Vecchio.Campo → Nuovo.Campo); l'inspector dice quante occorrenze verranno toccate |
| 9 | Step ed Experiment non sono supportati; OrchestratedStage lo e' | I due non supportati sono filtrati dalla palette via isSupported e, se arrivano da un import, l'inspector li mostra in sola lettura spiegando perche'. Lo stage ha il suo form: step non presentati come sequenza, e la condizione che referenzia l'output di un altro step segnalata con la via d'uscita |
| 10 | Un Delete senza filtri e' un errore, un Update senza filtri un avviso | RecordWriteInspectorComponent: severita' diverse, e l'update di massa chiede una conferma esplicita |
| 11 | Non cablare enum e operatori | Ogni tendina legge da getDictionaries(); le mappe locali intervengono solo come fallback quando il dizionario non e' disponibile |
| 12 | Il token di continuazione e' opaco | DebugPanelComponent lo rimanda tale e quale, non lo legge e non lo mette in nessun URL |
| 13 | Testo e numero non si confrontano (CONDITION_TYPE_MISMATCH) | ConditionEditorComponent conosce il tipo del lato sinistro da POST /flows/references: filtra gli operatori applicabili, guida il campo del letterale del secondo operando e segnala la coppia vietata. La regola sta in core/condition-types.util.ts, che salta gli operatori con semantica propria (Contains, In, …). I numeri si scrivono in cultura invariante: 1234,50 viene tradotto in 1234.50, non troncato |
| 14 | None non e' un operatore, e' un "da completare" | Condizione evidenziata come incompleta, con il conteggio in testa alla sezione e la frase «la bozza si salva, l'attivazione no»; il validatore della demo lo emette come errore |
| 15 | Un valore data senza Z significa ora locale | ValueEditorComponent ha un selettore «Ora locale / UTC (Z)», dice che cambiare fuso riscrive l'orario e non lo converte, e toglie il suffisso solo per il controllo datetime-local, che con il fuso resterebbe vuoto |
| 16 | Structure e Object non sono intercambiabili | StructurePickerComponent per la classe (fuori catalogo = errore, STRUCTURE_TYPE_UNKNOWN) e StructureMemberPickerComponent per i membri, filtrati su isWritable dove serve una destinazione. Il pannello risorse mostra la classe come obbligatoria con severita' di errore e non mostra il valore iniziale, perche' un structureValue non esiste; ReferencePickerComponent propone Variabile.Membro e verifica il percorso fino in fondo con core/path-navigation.ts, con PATH_NOT_VERIFIABLE dove la catena si interrompe; il Transform con target classe chiede un membro invece di un campo. FlowDictionaryStore.isStructure legge il flag dal dizionario, non dal nome del tipo. La risposta dei membri e' la descrizione della classe ({className, label, members}), e i suoi tre esiti restano distinti fino alla UI: FlowCatalogStore.describeStructure li traduce in declared / undeclared / unknown-class / unknown-catalog, e solo declared autorizza a dire che un membro non esiste (§4.7.1) |
| 17 | Due body diversi per due famiglie di primitive | HttpFlowBuilderApi: le primitive che analizzano il documento — validate, parse, references, references/writable, connector-targets, outline — lo mandano nudo alla radice; l'involucro {definition, flowName, …} resta alle sole primitive di scrittura della §6.2. Sbagliare involucro non da' nessun errore: il backend legge un flow vuoto e risponde 200. Per lo stesso motivo getReferences/getWritableReferences prendono la FlowDefinition e non un oggetto query: non c'e' nessun campo di filtro nel body, e il filtro di tipo lo applica l'editor con core/reference-filter.util.ts |
| 18 | Gli enum si scrivono come stringhe | Il modello e' fatto di unioni di stringhe letterali (FlowDataType, FlowVersionStatus, …) e non di enum numerici: JSON.stringify non ha modo di produrre un ordinale. Conta perche' "status": 3 non e' Draft ma InvalidDraft e "dataType": 0 e' String, non "non specificato" — nessun errore lo segnalerebbe. Chi scrive un'altra implementazione di FlowBuilderApi non traduca i valori in numeri |
| 19 | Un campo di screen dinamico e' una risorsa, ma di sola lettura | Lo restituisce POST /flows/references con kind: "ScreenField", e il reference picker lo propone in un gruppo suo; nei selettori di destinazione non compare, perche' references/writable lo esclude. L'unica eccezione e' l'assignToReference di una screen action, dove il campo di quella schermata e' una destinazione e l'elenco lo passa il chiamante (extraReferences). Il nome entra in FlowDocumentStore.usedNames, quindi un campo omonimo di una variabile e' NAME_DUPLICATED, e renameScreenField riscrive i riferimenti come renameNode |
| 20 | La validazione di uno screen dinamico la fa il server e non fa avanzare il flow | Riguarda il runtime, non l'editor: EXECUTION_FRONTEND.md §5.5 e §6.2, e lato client flow-execution (isValidationRetry, indexValidationErrors). Qui l'editor fa la sua parte a monte: validationRule si scrive con <fb-formula-editor>, che la fa verificare mentre si digita (§6.3) |
| 21 | Un campo nascosto viene azzerato all'invio | L'inspector lo dice dove si scrive la visibilityRule: la regola si rivaluta sui valori appena inviati e il campo che sparisce perde il valore. È l'unica cosa che chi disegna il flow non puo' dedurre dal metadata, e per questo sta nel form e non solo in questa tabella |
Altre regole del contratto rispettate: liste vuote omesse invece di scritte come [] (§2),
filterLogic mostrato solo dove il modello lo prevede (§4.4), fullName / status mai
usati per rinominare o attivare (§2), un solo campo valorizzato in
FlowElementReferenceOrValue con segnalazione e normalizzazione dei documenti ambigui (§4.2),
Transform.connector e start trattati come array per fedelta' storica (§3.5), salvataggio
possibile anche con errori e attivazione bloccata dagli errori (§8).
Limiti noti
- Le uscite indicizzate cambiano chiave se si riordinano le regole: la chiave di un
connettore contiene l'indice (
rule:0), quindi riordinare le regole di una Decision riassegna gli id dei connettori sul canvas. Le destinazioni seguono la regola, perche' vivono dentrorules[i].connector, ma un arco selezionato perde la selezione. relatedRecordsdi Get Records e' esposto in sola lettura con un avviso: e' modellato ma non tradotto in query.- La verifica delle formule e' del sistema ospite: senza un motore configurato l'editor non ha nulla da dire sulla sintassi, e lo dice — non finge una conferma. La libreria non prova a supplire con un parser proprio: una grammatica indovinata segnalerebbe come sbagliate le espressioni che il motore vero accetta.
- Il compositore dello screen dinamico non usa le drop list del CDK: annidate sono ambigue
— il rettangolo di una sezione contiene quello delle sue colonne, e a ricevere il rilascio e'
sempre la lista registrata per prima, cioe' la radice. Il bersaglio si calcola dal DOM
(
elementFromPointper il contenitore piu' interno, i rettangoli dei fratelli per la posizione). - L'esecutore della demo non valuta le condizioni: prende il primo ramo disponibile e lo scrive nella traccia. È un mock per esercitare il pannello, non il motore.
- Nessun test automatico: la libreria e' stata verificata a mano sull'app demo (canvas,
inspector, salvataggio, attivazione, interview completa). Casi su
condition-logic.util,flow-name.util,element-outletseFlowDocumentStoresarebbero il primo investimento sensato.
