diff --git a/apps/example-apps/call-center/README.md b/apps/example-apps/call-center/README.md index 57d6bbaa..2811d7e6 100644 --- a/apps/example-apps/call-center/README.md +++ b/apps/example-apps/call-center/README.md @@ -17,18 +17,51 @@ Call-center IVR and routing-builder reference app built with Angular and Foblex - `f-flow` as the workflow-editor root. - `f-canvas` with `fZoom` for panning and scaling. - `fExternalItem` with `fCreateNode` for palette-based node creation. -- `fNode`, `fNodeInput`, and `fNodeOutput` for call-flow steps and connectors. +- `fNode` and the unified `fConnector` API for call-flow steps and connectors. - `fConnection` with segmented routing and `fConnectionMarkerArrow` for directional links. - `fConnectionForCreate` for drag-to-connect creation. -- `fMoveNodes`, `fCreateConnection`, `fReassignConnection`, and `fSelectionChange` events for editor state updates. +- `withFlowState()` for typed records, automatic gesture updates, undo/redo, and persistence. +- `withReflowOnResize()` for moving downstream steps when an embedded form changes node size. +- `withA11y()` and the default control scheme for keyboard and pointer interaction. - `fBackground`, `fCirclePattern`, `fLineAlignment`, `fSelectionArea`, and `fMinimap` for canvas tooling. ## Integration Notes -- The app keeps editor state in a local signal store and persists it to local storage. +- `CallCenterFlowState` owns editor history and domain commands; it delegates persistence to `CallCenterFlowStorage` and record construction to domain factories. - Each node type exposes its own Angular Material form, so configuration lives directly inside the flow node instead of a separate side panel. - IVR output count is dynamic. When branch count is reduced, the app automatically removes now-invalid outbound connections from removed outputs. +## One History Item for Expand and Reflow + +Expanding a node updates `isExpanded` immediately, but `withReflowOnResize()` moves downstream nodes later, after Angular renders the new size and `ResizeObserver` runs. A synchronous `batch()` would close too early and create two undo items. + +The state keeps one transaction open across that rendering turn: + +```ts +function afterResizeObserverTurn(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +public async setNodeExpanded(nodeId: string, isExpanded: boolean): Promise { + this.beginBatch(); + + try { + this.updateNode(nodeId, { isExpanded }); + await afterResizeObserverTurn(); + } finally { + this.endBatch(); + } +} +``` + +The state controller receives reflow's later `fMoveNodes` event while the transaction is open. One `undo()` then restores both `isExpanded` and every shifted position. If node size settles after a transition, lazy load, or another asynchronous operation, close the transaction from that operation's completion signal instead of relying on two animation frames. + +The expand button also uses `fDragBlocker` because it lives inside `fDragHandle`. Without the blocker, the click can select the node first; when `selectionInHistory` is enabled, that intentional selection becomes a separate undo item before expand/reflow. + +Full guide: + ## Run Locally From this directory: diff --git a/apps/example-apps/call-center/package.json b/apps/example-apps/call-center/package.json index cf3cedfa..8abbecbe 100644 --- a/apps/example-apps/call-center/package.json +++ b/apps/example-apps/call-center/package.json @@ -16,7 +16,7 @@ "@angular/material": "18.2.14", "@angular/platform-browser": "18.2.13", "@foblex/2d": "^1.2.1", - "@foblex/flow": "18.4.0", + "@foblex/flow": "19.1.2", "@foblex/mediator": "1.1.3", "@foblex/platform": "1.0.4", "@foblex/utils": "1.1.1", diff --git a/apps/example-apps/call-center/src/app/app.component.html b/apps/example-apps/call-center/src/app/app.component.html index ebd1e428..50e98bc0 100644 --- a/apps/example-apps/call-center/src/app/app.component.html +++ b/apps/example-apps/call-center/src/app/app.component.html @@ -1 +1 @@ - + diff --git a/apps/example-apps/call-center/src/app/app.component.ts b/apps/example-apps/call-center/src/app/app.component.ts index 18ea0189..ca2bdb22 100644 --- a/apps/example-apps/call-center/src/app/app.component.ts +++ b/apps/example-apps/call-center/src/app/app.component.ts @@ -1,11 +1,11 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { Flow } from './components/flow/flow'; +import { CallCenterFlow } from './components/call-center-flow/call-center-flow'; @Component({ selector: 'app-root', standalone: true, templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [Flow], + imports: [CallCenterFlow], }) export class AppComponent {} diff --git a/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.html b/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.html new file mode 100644 index 00000000..b76dd56a --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.html @@ -0,0 +1,117 @@ +
+ + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + +
diff --git a/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.scss b/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.scss similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.scss rename to apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.scss diff --git a/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.ts b/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.ts new file mode 100644 index 00000000..b9eb5293 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/call-center-flow-toolbar.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; +import { ECallCenterFlowAction } from './e-call-center-flow-action'; +import { MatTooltip } from '@angular/material/tooltip'; +import { CallCenterFlowState } from '../../state'; +import { ThemeService } from '../../services/theme.service'; +import { MatIcon } from '@angular/material/icon'; +import { MatIconButton } from '@angular/material/button'; +import { FormsModule } from '@angular/forms'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; + +@Component({ + selector: 'call-center-flow-toolbar', + templateUrl: './call-center-flow-toolbar.html', + styleUrls: ['./call-center-flow-toolbar.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatTooltip, MatIcon, MatIconButton, FormsModule, MatMenuTrigger, MatMenuItem, MatMenu], +}) +export class CallCenterFlowToolbar { + protected readonly state = inject(CallCenterFlowState); + private readonly _themeService = inject(ThemeService); + + public readonly actionTriggered = output(); + public readonly resetRequested = output(); + + protected readonly action = ECallCenterFlowAction; + protected readonly currentTheme = this._themeService.current; + protected readonly canDeleteSelection = this.state.canDeleteSelection; + + protected deleteSelection(): void { + this.state.deleteSelection(); + } + + protected setTheme(theme: 'light' | 'dark'): void { + this._themeService.setTheme(theme); + } + + protected requestReset(): void { + this.resetRequested.emit(); + } +} diff --git a/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel-action.ts b/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/e-call-center-flow-action.ts similarity index 80% rename from apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel-action.ts rename to apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/e-call-center-flow-action.ts index 13921f77..0f4fab2a 100644 --- a/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel-action.ts +++ b/apps/example-apps/call-center/src/app/components/call-center-flow-toolbar/e-call-center-flow-action.ts @@ -1,4 +1,4 @@ -export enum FlowActionPanelAction { +export enum ECallCenterFlowAction { ZOOM_IN = 'ZOOM_IN', ZOOM_OUT = 'ZOOM_OUT', diff --git a/apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.html b/apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.html new file mode 100644 index 00000000..dd8179c3 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.html @@ -0,0 +1,52 @@ + + + + + + + + + + + @for (connection of connections(); track connection.id) { + + + + } + + + + + + @for (node of nodes(); track node.id) { + + } + + + diff --git a/apps/example-apps/call-center/src/app/components/flow/flow.scss b/apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.scss similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow/flow.scss rename to apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.scss diff --git a/apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.ts b/apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.ts new file mode 100644 index 00000000..976e70c3 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-flow/call-center-flow.ts @@ -0,0 +1,118 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, + OnInit, + viewChild, +} from '@angular/core'; +import { + EFReflowAxis, + EFReflowCollision, + EFReflowDeltaSource, + EFReflowMode, + EFReflowScope, + F_DEFAULT_CONTROL_SCHEME, + FCanvasComponent, + FFlowComponent, + FFlowModule, + FFlowState, + FZoomDirective, + provideFFlow, + withA11y, + withControlScheme, + withFlowState, + withReflowOnResize, +} from '@foblex/flow'; +import { CallCenterNode } from '../call-center-node/call-center-node'; +import { CallCenterFlowToolbar } from '../call-center-flow-toolbar/call-center-flow-toolbar'; +import { CallCenterNodePalette } from '../call-center-node-palette/call-center-node-palette'; +import { ECallCenterFlowAction } from '../call-center-flow-toolbar/e-call-center-flow-action'; +import { CallCenterNodeRecord, CALL_CENTER_NODE_METADATA } from '../../domain'; +import { CallCenterFlowState } from '../../state'; +import { CallCenterFlowStorage } from '../../persistence/call-center-flow-storage'; + +@Component({ + selector: 'call-center-flow', + templateUrl: './call-center-flow.html', + styleUrls: ['./call-center-flow.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FFlowModule, CallCenterNode, CallCenterFlowToolbar, CallCenterNodePalette], + providers: [ + provideFFlow( + { id: 'main' }, + withFlowState({ stateClass: CallCenterFlowState, canvasTransformDebounce: 200 }), + withA11y({ moveStep: 20 }), + withControlScheme(F_DEFAULT_CONTROL_SCHEME), + withReflowOnResize({ + mode: EFReflowMode.DOWNSTREAM_CONNECTIONS, + collision: EFReflowCollision.CHAIN_PUSH, + scope: EFReflowScope.CONNECTED_SUBGRAPH, + axis: EFReflowAxis.VERTICAL, + deltaSource: EFReflowDeltaSource.EDGE_BASED, + spacing: { vertical: 24 }, + maxCascadeDepth: 8, + maxAbsoluteShiftPerPlan: 10000, + }), + ), + { provide: CallCenterFlowState, useExisting: FFlowState }, + CallCenterFlowStorage, + ], +}) +export class CallCenterFlow implements OnInit { + public readonly flowId = input.required(); + + protected readonly state = inject(CallCenterFlowState); + + private readonly _flow = viewChild(FFlowComponent); + private readonly _canvas = viewChild(FCanvasComponent); + private readonly _zoom = viewChild(FZoomDirective); + + protected readonly nodes = this.state.nodes; + protected readonly connections = this.state.connections; + protected readonly transform = this.state.transform; + + public ngOnInit(): void { + this.state.initialize(this.flowId()); + } + + protected resetFlow(): void { + this._flow()?.reset(); + this.state.reset(); + } + + protected editorLoaded(): void { + if (this.transform().position) { + return; + } + + this._canvas()?.fitToScreen({ x: 150, y: 150 }, false, false); + } + + protected nodeAriaLabel(node: CallCenterNodeRecord): string { + const name = CALL_CENTER_NODE_METADATA[node.type].name; + + return node.description ? `${name}: ${node.description}` : name; + } + + protected executeAction(event: ECallCenterFlowAction): void { + switch (event) { + case ECallCenterFlowAction.SELECT_ALL: + this._flow()?.selectAll(); + break; + case ECallCenterFlowAction.ZOOM_IN: + this._zoom()?.zoomIn(); + break; + case ECallCenterFlowAction.ZOOM_OUT: + this._zoom()?.zoomOut(); + break; + case ECallCenterFlowAction.FIT_TO_SCREEN: + this._canvas()?.fitToScreen(); + break; + case ECallCenterFlowAction.ONE_TO_ONE: + this._canvas()?.resetScaleAndCenter(); + break; + } + } +} diff --git a/apps/example-apps/call-center/src/app/components/flow-palette/flow-palette.html b/apps/example-apps/call-center/src/app/components/call-center-node-palette/call-center-node-palette.html similarity index 77% rename from apps/example-apps/call-center/src/app/components/flow-palette/flow-palette.html rename to apps/example-apps/call-center/src/app/components/call-center-node-palette/call-center-node-palette.html index 4583a84b..24f9c444 100644 --- a/apps/example-apps/call-center/src/app/components/flow-palette/flow-palette.html +++ b/apps/example-apps/call-center/src/app/components/call-center-node-palette/call-center-node-palette.html @@ -1,8 +1,9 @@ -@for (item of nodes(); track item.type) { +@for (item of items(); track item.type) { } diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-header/flow-node-header.scss b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node-header/call-center-node-header.scss similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-node/flow-node-header/flow-node-header.scss rename to apps/example-apps/call-center/src/app/components/call-center-node/call-center-node-header/call-center-node-header.scss diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node-header/call-center-node-header.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node-header/call-center-node-header.ts new file mode 100644 index 00000000..24e401ee --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node-header/call-center-node-header.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { FFlowModule } from '@foblex/flow'; +import { CALL_CENTER_NODE_METADATA, ECallCenterNodeType } from '../../../domain'; + +@Component({ + selector: 'call-center-node-header', + templateUrl: './call-center-node-header.html', + styleUrls: ['./call-center-node-header.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FFlowModule, MatIcon], +}) +export class CallCenterNodeHeader { + public readonly expanded = input(false); + public readonly description = input.required(); + public readonly type = input.required(); + public readonly expandedChange = output(); + + protected readonly metadata = CALL_CENTER_NODE_METADATA; + + protected toggle(): void { + this.expandedChange.emit(!this.expanded()); + } +} diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.html b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.html new file mode 100644 index 00000000..cafda627 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.html @@ -0,0 +1,62 @@ +@let nodeModel = node(); + + + +@if (isExpanded()) { +
+ @switch (nodeModel.type) { @case (nodeType.USER_INPUT) { + + } @case (nodeType.PLAY_TEXT) { + + } @case (nodeType.CHECK_SCHEDULE) { + + } @case (nodeType.QUEUE_CALL) { + + } @case (nodeType.TO_OPERATOR) { + + } @case (nodeType.TRANSFER_CALL) { + + } @case (nodeType.VOICEMAIL) { + + } } +
+} + +
+ @for (output of nodeModel.outputs; track output.id) { +
+ @if (isExpanded()) { + {{ output.label }} + } +
+ @if (connectedOutputIds().has(output.id)) { + + } +
+
+ } +
diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.scss b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.scss new file mode 100644 index 00000000..9c442b64 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.scss @@ -0,0 +1,44 @@ +// Styles for .node-form, .form-row, .mat-mdc-checkbox are in global _material.scss +// (needed to penetrate child form components under Angular Emulated encapsulation) + +.node-outputs { + display: flex; + flex-direction: row-reverse; + justify-content: space-evenly; + align-items: flex-end; + padding-bottom: 2px; + + .output-item { + display: contents; + } + + .flow-output-connector { + margin-bottom: -12px; + } + + &.expanded { + display: block; + padding-bottom: 0; + border-top: 1px solid var(--c-border); + + .output-item { + width: calc(100% + 28px); + height: 34px; + margin-left: -14px; + padding: 0 14px; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--c-text-subtle); + font-size: var(--fs-md); + + & + .output-item { + border-top: 1px solid var(--c-border); + } + } + + .flow-output-connector { + margin-bottom: 0; + } + } +} diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.ts new file mode 100644 index 00000000..44fdb2f3 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-node.ts @@ -0,0 +1,102 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + Injector, + input, + OnInit, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { EFConnectableSide, FFlowModule } from '@foblex/flow'; +import { distinctUntilChanged } from 'rxjs'; +import { CallCenterNodeRecord, CallCenterNodeValuePatch, ECallCenterNodeType } from '../../domain'; +import { CallCenterFlowState } from '../../state'; +import { CallCenterNodeHeader } from './call-center-node-header/call-center-node-header'; +import { CallCenterIvrForm } from './call-center-ivr-form/call-center-ivr-form'; +import { CallCenterOperatorForm } from './call-center-operator-form/call-center-operator-form'; +import { CallCenterPlayTextForm } from './call-center-play-text-form/call-center-play-text-form'; +import { CallCenterQueueForm } from './call-center-queue-form/call-center-queue-form'; +import { CallCenterScheduleForm } from './call-center-schedule-form/call-center-schedule-form'; +import { CallCenterTransferForm } from './call-center-transfer-form/call-center-transfer-form'; +import { CallCenterVoicemailForm } from './call-center-voicemail-form/call-center-voicemail-form'; + +@Component({ + selector: 'call-center-node', + templateUrl: './call-center-node.html', + styleUrls: ['./call-center-node.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FFlowModule, + ReactiveFormsModule, + MatIcon, + CallCenterNodeHeader, + CallCenterIvrForm, + CallCenterPlayTextForm, + CallCenterScheduleForm, + CallCenterQueueForm, + CallCenterOperatorForm, + CallCenterTransferForm, + CallCenterVoicemailForm, + ], + host: { + '[class.invalid]': 'form.invalid && form.touched', + }, +}) +export class CallCenterNode implements OnInit { + private readonly _destroyRef = inject(DestroyRef); + private readonly _injector = inject(Injector); + private readonly _state = inject(CallCenterFlowState); + + public readonly node = input.required(); + + protected readonly form = new FormControl(null); + protected readonly isExpanded = computed(() => this.node().isExpanded ?? false); + protected readonly connectableSide = EFConnectableSide; + protected readonly nodeType = ECallCenterNodeType; + protected readonly connectedOutputIds = computed(() => { + return new Set(this._state.connections().map((connection) => connection.sourceId)); + }); + + public ngOnInit(): void { + this._listenToNodeChanges(); + this._listenToFormChanges(); + } + + protected setExpanded(isExpanded: boolean): void { + void this._state.setNodeExpanded(this.node().id, isExpanded); + } + + protected removeOutputConnection(outputId: string): void { + this._state.removeConnectionFromOutput(outputId); + } + + private _listenToFormChanges(): void { + this.form.valueChanges + .pipe( + distinctUntilChanged((previous, current) => _areEqual(previous, current)), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe((value) => this._state.updateNodeValue(this.node().id, value)); + } + + private _listenToNodeChanges(): void { + effect( + () => { + const value = this.node().value; + untracked(() => this.form.setValue(value, { emitEvent: false })); + }, + { injector: this._injector }, + ); + } +} + +function _areEqual(left: TValue, right: TValue): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-operator-form/flow-node-operator-form.html b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-operator-form/call-center-operator-form.html similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-node/flow-node-operator-form/flow-node-operator-form.html rename to apps/example-apps/call-center/src/app/components/call-center-node/call-center-operator-form/call-center-operator-form.html diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-operator-form/call-center-operator-form.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-operator-form/call-center-operator-form.ts new file mode 100644 index 00000000..13e3d30b --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-operator-form/call-center-operator-form.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, forwardRef, inject } from '@angular/core'; +import { MatFormField } from '@angular/material/form-field'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { FormBuilder, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { + IOperatorNodeValue, + ISelectOption, + OperatorDepartment, + OperatorSkillLevel, +} from '../../../domain'; +import { + CallCenterNodeFormControl, + CallCenterNodeFormControls, +} from '../call-center-node-form-control'; + +@Component({ + selector: 'call-center-operator-form', + templateUrl: './call-center-operator-form.html', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatFormField, MatSelect, MatOption, ReactiveFormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CallCenterOperatorForm), + multi: true, + }, + ], +}) +export class CallCenterOperatorForm extends CallCenterNodeFormControl { + private readonly _formBuilder = inject(FormBuilder); + + protected readonly form = this._formBuilder.group>( + { + department: this._formBuilder.control('general'), + skillLevel: this._formBuilder.control('any'), + }, + ); + + protected readonly departments: readonly ISelectOption[] = [ + { key: 'general', value: 'General' }, + { key: 'sales', value: 'Sales' }, + { key: 'support', value: 'Technical Support' }, + { key: 'billing', value: 'Billing' }, + { key: 'complaints', value: 'Complaints' }, + ]; + + protected readonly skillLevels: readonly ISelectOption[] = [ + { key: 'any', value: 'Any' }, + { key: 'junior', value: 'Junior' }, + { key: 'senior', value: 'Senior' }, + { key: 'manager', value: 'Manager' }, + ]; +} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/flow-node-play-text-form.html b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-play-text-form/call-center-play-text-form.html similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/flow-node-play-text-form.html rename to apps/example-apps/call-center/src/app/components/call-center-node/call-center-play-text-form/call-center-play-text-form.html diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-play-text-form/call-center-play-text-form.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-play-text-form/call-center-play-text-form.ts new file mode 100644 index 00000000..0e2fcc74 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-play-text-form/call-center-play-text-form.ts @@ -0,0 +1,44 @@ +import { ChangeDetectionStrategy, Component, forwardRef, inject } from '@angular/core'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { IPlayTextNodeValue } from '../../../domain'; +import { + CallCenterNodeFormControl, + CallCenterNodeFormControls, +} from '../call-center-node-form-control'; + +@Component({ + selector: 'call-center-play-text-form', + templateUrl: './call-center-play-text-form.html', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatFormField, ReactiveFormsModule, MatInput], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CallCenterPlayTextForm), + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CallCenterPlayTextForm), + multi: true, + }, + ], +}) +export class CallCenterPlayTextForm extends CallCenterNodeFormControl { + private readonly _formBuilder = inject(FormBuilder); + + protected readonly form = this._formBuilder.group>( + { + text: this._formBuilder.control(null, [Validators.required]), + }, + ); +} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-queue-form/flow-node-queue-form.html b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-queue-form/call-center-queue-form.html similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-node/flow-node-queue-form/flow-node-queue-form.html rename to apps/example-apps/call-center/src/app/components/call-center-node/call-center-queue-form/call-center-queue-form.html diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-queue-form/call-center-queue-form.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-queue-form/call-center-queue-form.ts new file mode 100644 index 00000000..f4e4118e --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-queue-form/call-center-queue-form.ts @@ -0,0 +1,53 @@ +import { ChangeDetectionStrategy, Component, forwardRef, inject } from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { FormBuilder, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { IQueueCallNodeValue, ISelectOption, QueuePriority } from '../../../domain'; +import { + CallCenterNodeFormControl, + CallCenterNodeFormControls, +} from '../call-center-node-form-control'; + +@Component({ + selector: 'call-center-queue-form', + templateUrl: './call-center-queue-form.html', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatFormField, MatInput, MatSelect, MatOption, MatCheckbox, ReactiveFormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CallCenterQueueForm), + multi: true, + }, + ], +}) +export class CallCenterQueueForm extends CallCenterNodeFormControl { + private readonly _formBuilder = inject(FormBuilder); + + protected readonly form = this._formBuilder.group< + CallCenterNodeFormControls + >({ + queueName: this._formBuilder.control('default'), + maxWaitTime: this._formBuilder.control(300), + priority: this._formBuilder.control('normal'), + musicOnHold: this._formBuilder.control(true), + }); + + protected readonly priorities: readonly ISelectOption[] = [ + { key: 'low', value: 'Low' }, + { key: 'normal', value: 'Normal' }, + { key: 'high', value: 'High' }, + { key: 'urgent', value: 'Urgent' }, + ]; + + protected readonly waitTimes: readonly ISelectOption[] = [ + { key: 60, value: '1 min' }, + { key: 120, value: '2 min' }, + { key: 300, value: '5 min' }, + { key: 600, value: '10 min' }, + { key: 900, value: '15 min' }, + ]; +} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-schedule-form/flow-node-schedule-form.html b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-schedule-form/call-center-schedule-form.html similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-node/flow-node-schedule-form/flow-node-schedule-form.html rename to apps/example-apps/call-center/src/app/components/call-center-node/call-center-schedule-form/call-center-schedule-form.html diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-schedule-form/call-center-schedule-form.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-schedule-form/call-center-schedule-form.ts new file mode 100644 index 00000000..357df212 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-schedule-form/call-center-schedule-form.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, forwardRef, inject } from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { FormBuilder, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { ICheckScheduleNodeValue } from '../../../domain'; +import { + CallCenterNodeFormControl, + CallCenterNodeFormControls, +} from '../call-center-node-form-control'; + +@Component({ + selector: 'call-center-schedule-form', + templateUrl: './call-center-schedule-form.html', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatFormField, MatInput, MatSelect, MatOption, MatCheckbox, ReactiveFormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CallCenterScheduleForm), + multi: true, + }, + ], +}) +export class CallCenterScheduleForm extends CallCenterNodeFormControl { + private readonly _formBuilder = inject(FormBuilder); + + protected readonly form = this._formBuilder.group< + CallCenterNodeFormControls + >({ + startTime: this._formBuilder.control('09:00'), + endTime: this._formBuilder.control('18:00'), + timezone: this._formBuilder.control('UTC'), + weekends: this._formBuilder.control(false), + }); + + protected readonly timezones = [ + 'UTC', + 'US/Eastern', + 'US/Central', + 'US/Pacific', + 'Europe/London', + 'Europe/Berlin', + 'Asia/Tokyo', + ]; +} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-transfer-form/flow-node-transfer-form.html b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-transfer-form/call-center-transfer-form.html similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-node/flow-node-transfer-form/flow-node-transfer-form.html rename to apps/example-apps/call-center/src/app/components/call-center-node/call-center-transfer-form/call-center-transfer-form.html diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-transfer-form/call-center-transfer-form.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-transfer-form/call-center-transfer-form.ts new file mode 100644 index 00000000..052bbd61 --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-transfer-form/call-center-transfer-form.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, forwardRef, inject } from '@angular/core'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { FormBuilder, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { ISelectOption, ITransferCallNodeValue } from '../../../domain'; +import { + CallCenterNodeFormControl, + CallCenterNodeFormControls, +} from '../call-center-node-form-control'; + +@Component({ + selector: 'call-center-transfer-form', + templateUrl: './call-center-transfer-form.html', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatFormField, MatInput, MatSelect, MatOption, ReactiveFormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CallCenterTransferForm), + multi: true, + }, + ], +}) +export class CallCenterTransferForm extends CallCenterNodeFormControl { + private readonly _formBuilder = inject(FormBuilder); + + protected readonly form = this._formBuilder.group< + CallCenterNodeFormControls + >({ + phoneNumber: this._formBuilder.control(''), + ringTimeout: this._formBuilder.control(30), + }); + + protected readonly ringTimeouts: readonly ISelectOption[] = [ + { key: 15, value: '15 sec' }, + { key: 30, value: '30 sec' }, + { key: 45, value: '45 sec' }, + { key: 60, value: '60 sec' }, + ]; +} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-voicemail-form/flow-node-voicemail-form.html b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-voicemail-form/call-center-voicemail-form.html similarity index 100% rename from apps/example-apps/call-center/src/app/components/flow-node/flow-node-voicemail-form/flow-node-voicemail-form.html rename to apps/example-apps/call-center/src/app/components/call-center-node/call-center-voicemail-form/call-center-voicemail-form.html diff --git a/apps/example-apps/call-center/src/app/components/call-center-node/call-center-voicemail-form/call-center-voicemail-form.ts b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-voicemail-form/call-center-voicemail-form.ts new file mode 100644 index 00000000..2fc1c45d --- /dev/null +++ b/apps/example-apps/call-center/src/app/components/call-center-node/call-center-voicemail-form/call-center-voicemail-form.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, forwardRef, inject } from '@angular/core'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatOption, MatSelect } from '@angular/material/select'; +import { FormBuilder, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { ISelectOption, IVoicemailNodeValue } from '../../../domain'; +import { + CallCenterNodeFormControl, + CallCenterNodeFormControls, +} from '../call-center-node-form-control'; + +@Component({ + selector: 'call-center-voicemail-form', + templateUrl: './call-center-voicemail-form.html', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatFormField, MatInput, MatSelect, MatOption, ReactiveFormsModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CallCenterVoicemailForm), + multi: true, + }, + ], +}) +export class CallCenterVoicemailForm extends CallCenterNodeFormControl { + private readonly _formBuilder = inject(FormBuilder); + + protected readonly form = this._formBuilder.group< + CallCenterNodeFormControls + >({ + greeting: this._formBuilder.control('Please leave a message after the beep.'), + maxDuration: this._formBuilder.control(120), + }); + + protected readonly durations: readonly ISelectOption[] = [ + { key: 30, value: '30 sec' }, + { key: 60, value: '1 min' }, + { key: 120, value: '2 min' }, + { key: 300, value: '5 min' }, + ]; +} diff --git a/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.html b/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.html deleted file mode 100644 index 57913dee..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.html +++ /dev/null @@ -1,82 +0,0 @@ -
- - - - -
- - - - - - - - - -
- - - - - - - - - -
diff --git a/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.ts b/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.ts deleted file mode 100644 index 5bf89823..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-action-panel/flow-action-panel.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core'; -import { FlowActionPanelAction } from './flow-action-panel-action'; -import { MatTooltip } from '@angular/material/tooltip'; -import { FlowStore } from '../../store/flow-store'; -import { ThemeService } from '../../services/theme.service'; -import { MatIcon } from '@angular/material/icon'; -import { MatIconButton } from '@angular/material/button'; -import { FormsModule } from '@angular/forms'; -import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; - -@Component({ - selector: 'flow-action-panel', - templateUrl: './flow-action-panel.html', - styleUrls: ['./flow-action-panel.scss'], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatTooltip, MatIcon, MatIconButton, FormsModule, MatMenuTrigger, MatMenuItem, MatMenu], -}) -export class FlowActionPanel { - protected readonly store = inject(FlowStore); - private readonly _themeService = inject(ThemeService); - - protected get currentTheme() { - return this._themeService.current; - } - - public readonly processAction = output(); - public readonly resetFlow = output(); - - protected readonly action = FlowActionPanelAction; - protected readonly canRemove = this.store.canRemove; - - protected removeSelected(): void { - this.store.removeSelected(); - } - - protected toggleTheme(theme: 'light' | 'dark'): void { - this._themeService.toggle(theme); - } - - protected reset(): void { - this.resetFlow.emit(); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.html b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.html deleted file mode 100644 index c33b2deb..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.html +++ /dev/null @@ -1,19 +0,0 @@ -@for (output of outputs(); track output.id) { -
- {{ output.label }} -
- close -
-
-} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.scss b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.scss deleted file mode 100644 index c64ed6b7..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.scss +++ /dev/null @@ -1,20 +0,0 @@ -:host { - display: block; - border-top: 1px solid var(--c-border); - - .output-item { - width: calc(100% + 28px); - display: flex; - align-items: center; - justify-content: space-between; - margin-left: -14px; - height: 34px; - padding: 0 14px; - font-size: var(--fs-md); - color: var(--c-text-subtle); - - & + .output-item { - border-top: 1px solid var(--c-border); - } - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.ts deleted file mode 100644 index 394da85b..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-body-outputs/flow-node-body-outputs.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component, input, output } from '@angular/core'; -import { FFlowModule } from '@foblex/flow'; -import { MatIcon } from '@angular/material/icon'; -import { INodeOutput } from '../../../domain'; - -@Component({ - selector: 'flow-node-body-outputs', - templateUrl: './flow-node-body-outputs.html', - styleUrls: ['./flow-node-body-outputs.scss'], - standalone: true, - imports: [FFlowModule, MatIcon], -}) -export class FlowNodeBodyOutputs { - public readonly removeConnection = output(); - - public readonly outputs = input.required(); - - protected removeOutputConnection(outputId: string, event?: Event): void { - event?.preventDefault(); - this.removeConnection.emit(outputId); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.html b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.html deleted file mode 100644 index 483fe40d..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.html +++ /dev/null @@ -1,16 +0,0 @@ -@for (output of outputs(); track output.id) { -
- close -
-} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.scss b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.scss deleted file mode 100644 index 889d863f..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.scss +++ /dev/null @@ -1,11 +0,0 @@ -:host { - display: flex; - flex-direction: row-reverse; - justify-content: space-evenly; - align-items: flex-end; - padding-bottom: 2px; - - .f-node-output { - margin-bottom: -12px; - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.ts deleted file mode 100644 index 4d49dd01..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-footer-outputs/flow-node-footer-outputs.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component, input, output } from '@angular/core'; -import { FFlowModule } from '@foblex/flow'; -import { MatIcon } from '@angular/material/icon'; -import { INodeOutput } from '../../../domain'; - -@Component({ - selector: 'flow-node-footer-outputs', - templateUrl: './flow-node-footer-outputs.html', - styleUrls: ['./flow-node-footer-outputs.scss'], - standalone: true, - imports: [FFlowModule, MatIcon], -}) -export class FlowNodeFooterOutputs { - public readonly removeConnection = output(); - - public readonly outputs = input.required(); - - protected removeOutputConnection(outputId: string, event?: Event): void { - event?.preventDefault(); - this.removeConnection.emit(outputId); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-header/flow-node-header.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-header/flow-node-header.ts deleted file mode 100644 index 66358b3a..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-header/flow-node-header.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, input, model } from '@angular/core'; -import { FFlowModule } from '@foblex/flow'; -import { MatIcon } from '@angular/material/icon'; -import { NODE_PARAMS_MAP, NodeType } from '../../../domain'; - -@Component({ - selector: 'flow-node-header', - templateUrl: './flow-node-header.html', - styleUrls: ['./flow-node-header.scss'], - standalone: true, - imports: [FFlowModule, MatIcon], -}) -export class FlowNodeHeader { - protected readonly defaultParams = NODE_PARAMS_MAP; - - public readonly expanded = model(false); - public readonly description = input.required(); - public readonly type = input.required(); - - protected toggle(): void { - this.expanded.update((x) => !x); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/flow-node-ivr-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/flow-node-ivr-form.ts deleted file mode 100644 index 13c18a91..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/flow-node-ivr-form.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Component, DestroyRef, forwardRef, inject, OnInit } from '@angular/core'; -import { MatFormField } from '@angular/material/form-field'; -import { MatOption, MatSelect } from '@angular/material/select'; -import { - ControlValueAccessor, - FormBuilder, - FormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, - ValidationErrors, - Validator, -} from '@angular/forms'; -import { IFlowNodeIvrForm } from './models/i-flow-node-ivr-form'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { distinctUntilChanged } from 'rxjs'; -import { IFlowNodeIvrFormValue } from './models/i-flow-node-ivr-form-value'; -import { IVR_FORM_OUTPUT_LIST } from './constants/ivr-form-output-list'; -import { INodeSelectOption } from '../../../domain'; - -@Component({ - selector: 'flow-node-ivr-form', - templateUrl: './flow-node-ivr-form.html', - standalone: true, - imports: [MatFormField, MatSelect, MatOption, ReactiveFormsModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlowNodeIvrForm), - multi: true, - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FlowNodeIvrForm), - multi: true, - }, - ], -}) -export class FlowNodeIvrForm implements OnInit, ControlValueAccessor, Validator { - private readonly _fb = inject(FormBuilder); - private readonly _destroyRef = inject(DestroyRef); - - private _onChange: (value: Partial) => void = () => {}; - private _onTouch: () => void = () => {}; - - protected form!: FormGroup; - - protected readonly outputs = IVR_FORM_OUTPUT_LIST; - protected readonly timeouts: readonly INodeSelectOption[] = [ - { key: 3, value: '3 sec' }, - { key: 5, value: '5 sec' }, - { key: 10, value: '10 sec' }, - { key: 15, value: '15 sec' }, - { key: 30, value: '30 sec' }, - ]; - - public ngOnInit(): void { - this._buildForm(); - this._listenFormChanges(); - } - - private _buildForm(): void { - this.form = this._fb.group({ - outputs: this._fb.control(3), - timeout: this._fb.control(5), - }); - } - - private _listenFormChanges(): void { - this.form.valueChanges - .pipe(distinctUntilChanged(), takeUntilDestroyed(this._destroyRef)) - .subscribe((value) => { - this._onChange?.(value); - this._onTouch?.(); - }); - } - - public registerOnChange(fn: (value: Partial) => void): void { - this._onChange = fn; - } - - public registerOnTouched(fn: () => void): void { - this._onTouch = fn; - } - - public validate(): ValidationErrors | null { - return this.form.invalid ? { invalid: true } : null; - } - - public writeValue(value?: IFlowNodeIvrFormValue | null): void { - this.form.patchValue(value ?? {}, { emitEvent: false }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/models/i-flow-node-ivr-form-value.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/models/i-flow-node-ivr-form-value.ts deleted file mode 100644 index cfa699e1..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/models/i-flow-node-ivr-form-value.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { IUserInputNodeValue } from '../../../../domain'; - -export type IFlowNodeIvrFormValue = IUserInputNodeValue; diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/models/i-flow-node-ivr-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/models/i-flow-node-ivr-form.ts deleted file mode 100644 index 56bc0b1e..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-ivr-form/models/i-flow-node-ivr-form.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { FormControl } from '@angular/forms'; - -export interface IFlowNodeIvrForm { - outputs: FormControl; - timeout: FormControl; -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-operator-form/flow-node-operator-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-operator-form/flow-node-operator-form.ts deleted file mode 100644 index 17b09747..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-operator-form/flow-node-operator-form.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Component, DestroyRef, forwardRef, inject, OnInit } from '@angular/core'; -import { MatFormField } from '@angular/material/form-field'; -import { MatOption, MatSelect } from '@angular/material/select'; -import { - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, -} from '@angular/forms'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - INodeSelectOption, - IOperatorNodeValue, - OperatorDepartment, - OperatorSkillLevel, -} from '../../../domain'; - -interface OperatorForm { - department: FormControl; - skillLevel: FormControl; -} - -@Component({ - selector: 'flow-node-operator-form', - templateUrl: './flow-node-operator-form.html', - standalone: true, - imports: [MatFormField, MatSelect, MatOption, ReactiveFormsModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlowNodeOperatorForm), - multi: true, - }, - ], -}) -export class FlowNodeOperatorForm implements OnInit, ControlValueAccessor { - private readonly _fb = inject(FormBuilder); - private readonly _destroyRef = inject(DestroyRef); - - private _onChange: (value: Partial) => void = () => {}; - private _onTouch: () => void = () => {}; - - protected form!: FormGroup; - - protected readonly departments: readonly INodeSelectOption[] = [ - { key: 'general', value: 'General' }, - { key: 'sales', value: 'Sales' }, - { key: 'support', value: 'Technical Support' }, - { key: 'billing', value: 'Billing' }, - { key: 'complaints', value: 'Complaints' }, - ]; - - protected readonly skillLevels: readonly INodeSelectOption[] = [ - { key: 'any', value: 'Any' }, - { key: 'junior', value: 'Junior' }, - { key: 'senior', value: 'Senior' }, - { key: 'manager', value: 'Manager' }, - ]; - - public ngOnInit(): void { - this.form = this._fb.group({ - department: this._fb.control('general'), - skillLevel: this._fb.control('any'), - }); - this.form.valueChanges.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((v) => { - this._onChange?.(v); - this._onTouch?.(); - }); - } - - public registerOnChange(fn: (value: Partial) => void): void { - this._onChange = fn; - } - - public registerOnTouched(fn: () => void): void { - this._onTouch = fn; - } - - public writeValue(value?: IOperatorNodeValue | null): void { - if (value) this.form.patchValue(value, { emitEvent: false }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/flow-node-play-text-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/flow-node-play-text-form.ts deleted file mode 100644 index 9d991412..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/flow-node-play-text-form.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Component, DestroyRef, forwardRef, inject, OnInit } from '@angular/core'; -import { MatFormField } from '@angular/material/form-field'; -import { - ControlValueAccessor, - FormBuilder, - FormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, - ValidationErrors, - Validator, - Validators, -} from '@angular/forms'; -import { IFlowNodePlayTextForm } from './models/i-flow-node-play-text-form'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { distinctUntilChanged } from 'rxjs'; -import { IFlowNodePlayTextFormValue } from './models/i-flow-node-play-text-form-value'; -import { MatInput } from '@angular/material/input'; - -@Component({ - selector: 'flow-node-play-text-form', - templateUrl: './flow-node-play-text-form.html', - standalone: true, - imports: [MatFormField, ReactiveFormsModule, MatInput], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlowNodePlayTextForm), - multi: true, - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FlowNodePlayTextForm), - multi: true, - }, - ], -}) -export class FlowNodePlayTextForm implements OnInit, ControlValueAccessor, Validator { - private readonly _fb = inject(FormBuilder); - private readonly _destroyRef = inject(DestroyRef); - - private _onChange: (value: Partial) => void = () => {}; - private _onTouch: () => void = () => {}; - - protected form!: FormGroup; - - public ngOnInit(): void { - this._buildForm(); - this._listenFormChanges(); - } - - private _buildForm(): void { - this.form = this._fb.group({ - text: this._fb.control(null, [Validators.required]), - }); - } - - private _listenFormChanges(): void { - this.form.valueChanges - .pipe(distinctUntilChanged(), takeUntilDestroyed(this._destroyRef)) - .subscribe((value) => { - this._onChange?.(value); - this._onTouch?.(); - }); - } - - public registerOnChange(fn: (value: Partial) => void): void { - this._onChange = fn; - } - - public registerOnTouched(fn: () => void): void { - this._onTouch = fn; - } - - public validate(): ValidationErrors | null { - return this.form.invalid ? { invalid: true } : null; - } - - public writeValue(value?: IFlowNodePlayTextFormValue | null): void { - this.form.patchValue(value ?? {}, { emitEvent: false }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/models/i-flow-node-play-text-form-value.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/models/i-flow-node-play-text-form-value.ts deleted file mode 100644 index 2a3321a3..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/models/i-flow-node-play-text-form-value.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { IPlayTextNodeValue } from '../../../../domain'; - -export type IFlowNodePlayTextFormValue = IPlayTextNodeValue; diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/models/i-flow-node-play-text-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/models/i-flow-node-play-text-form.ts deleted file mode 100644 index 6927ddd6..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-play-text-form/models/i-flow-node-play-text-form.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FormControl } from '@angular/forms'; - -export interface IFlowNodePlayTextForm { - text: FormControl; -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-queue-form/flow-node-queue-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-queue-form/flow-node-queue-form.ts deleted file mode 100644 index 27502ec7..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-queue-form/flow-node-queue-form.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Component, DestroyRef, forwardRef, inject, OnInit } from '@angular/core'; -import { MatFormField } from '@angular/material/form-field'; -import { MatInput } from '@angular/material/input'; -import { MatOption, MatSelect } from '@angular/material/select'; -import { MatCheckbox } from '@angular/material/checkbox'; -import { - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, -} from '@angular/forms'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { INodeSelectOption, IQueueCallNodeValue, QueuePriority } from '../../../domain'; - -interface QueueForm { - queueName: FormControl; - maxWaitTime: FormControl; - priority: FormControl; - musicOnHold: FormControl; -} - -@Component({ - selector: 'flow-node-queue-form', - templateUrl: './flow-node-queue-form.html', - standalone: true, - imports: [MatFormField, MatInput, MatSelect, MatOption, MatCheckbox, ReactiveFormsModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlowNodeQueueForm), - multi: true, - }, - ], -}) -export class FlowNodeQueueForm implements OnInit, ControlValueAccessor { - private readonly _fb = inject(FormBuilder); - private readonly _destroyRef = inject(DestroyRef); - - private _onChange: (value: Partial) => void = () => {}; - private _onTouch: () => void = () => {}; - - protected form!: FormGroup; - - protected readonly priorities: readonly INodeSelectOption[] = [ - { key: 'low', value: 'Low' }, - { key: 'normal', value: 'Normal' }, - { key: 'high', value: 'High' }, - { key: 'urgent', value: 'Urgent' }, - ]; - - protected readonly waitTimes: readonly INodeSelectOption[] = [ - { key: 60, value: '1 min' }, - { key: 120, value: '2 min' }, - { key: 300, value: '5 min' }, - { key: 600, value: '10 min' }, - { key: 900, value: '15 min' }, - ]; - - public ngOnInit(): void { - this.form = this._fb.group({ - queueName: this._fb.control('default'), - maxWaitTime: this._fb.control(300), - priority: this._fb.control('normal'), - musicOnHold: this._fb.control(true), - }); - this.form.valueChanges.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((v) => { - this._onChange?.(v); - this._onTouch?.(); - }); - } - - public registerOnChange(fn: (value: Partial) => void): void { - this._onChange = fn; - } - - public registerOnTouched(fn: () => void): void { - this._onTouch = fn; - } - - public writeValue(value?: IQueueCallNodeValue | null): void { - if (value) this.form.patchValue(value, { emitEvent: false }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-schedule-form/flow-node-schedule-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-schedule-form/flow-node-schedule-form.ts deleted file mode 100644 index f7c8eed8..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-schedule-form/flow-node-schedule-form.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Component, DestroyRef, forwardRef, inject, OnInit } from '@angular/core'; -import { MatFormField } from '@angular/material/form-field'; -import { MatInput } from '@angular/material/input'; -import { MatOption, MatSelect } from '@angular/material/select'; -import { - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, -} from '@angular/forms'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatCheckbox } from '@angular/material/checkbox'; -import { ICheckScheduleNodeValue } from '../../../domain'; - -interface ScheduleForm { - startTime: FormControl; - endTime: FormControl; - timezone: FormControl; - weekends: FormControl; -} - -@Component({ - selector: 'flow-node-schedule-form', - templateUrl: './flow-node-schedule-form.html', - standalone: true, - imports: [MatFormField, MatInput, MatSelect, MatOption, MatCheckbox, ReactiveFormsModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlowNodeScheduleForm), - multi: true, - }, - ], -}) -export class FlowNodeScheduleForm implements OnInit, ControlValueAccessor { - private readonly _fb = inject(FormBuilder); - private readonly _destroyRef = inject(DestroyRef); - - private _onChange: (value: Partial) => void = () => {}; - private _onTouch: () => void = () => {}; - - protected form!: FormGroup; - - protected readonly timezones = [ - 'UTC', - 'US/Eastern', - 'US/Central', - 'US/Pacific', - 'Europe/London', - 'Europe/Berlin', - 'Asia/Tokyo', - ]; - - public ngOnInit(): void { - this.form = this._fb.group({ - startTime: this._fb.control('09:00'), - endTime: this._fb.control('18:00'), - timezone: this._fb.control('UTC'), - weekends: this._fb.control(false), - }); - this.form.valueChanges.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((v) => { - this._onChange?.(v); - this._onTouch?.(); - }); - } - - public registerOnChange(fn: (value: Partial) => void): void { - this._onChange = fn; - } - - public registerOnTouched(fn: () => void): void { - this._onTouch = fn; - } - - public writeValue(value?: ICheckScheduleNodeValue | null): void { - if (value) this.form.patchValue(value, { emitEvent: false }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-transfer-form/flow-node-transfer-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-transfer-form/flow-node-transfer-form.ts deleted file mode 100644 index 483b2f28..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-transfer-form/flow-node-transfer-form.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Component, DestroyRef, forwardRef, inject, OnInit } from '@angular/core'; -import { MatFormField } from '@angular/material/form-field'; -import { MatInput } from '@angular/material/input'; -import { MatOption, MatSelect } from '@angular/material/select'; -import { - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, -} from '@angular/forms'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { INodeSelectOption, ITransferCallNodeValue } from '../../../domain'; - -interface TransferForm { - phoneNumber: FormControl; - ringTimeout: FormControl; -} - -@Component({ - selector: 'flow-node-transfer-form', - templateUrl: './flow-node-transfer-form.html', - standalone: true, - imports: [MatFormField, MatInput, MatSelect, MatOption, ReactiveFormsModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlowNodeTransferForm), - multi: true, - }, - ], -}) -export class FlowNodeTransferForm implements OnInit, ControlValueAccessor { - private readonly _fb = inject(FormBuilder); - private readonly _destroyRef = inject(DestroyRef); - - private _onChange: (value: Partial) => void = () => {}; - private _onTouch: () => void = () => {}; - - protected form!: FormGroup; - - protected readonly ringTimeouts: readonly INodeSelectOption[] = [ - { key: 15, value: '15 sec' }, - { key: 30, value: '30 sec' }, - { key: 45, value: '45 sec' }, - { key: 60, value: '60 sec' }, - ]; - - public ngOnInit(): void { - this.form = this._fb.group({ - phoneNumber: this._fb.control(''), - ringTimeout: this._fb.control(30), - }); - this.form.valueChanges.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((v) => { - this._onChange?.(v); - this._onTouch?.(); - }); - } - - public registerOnChange(fn: (value: Partial) => void): void { - this._onChange = fn; - } - - public registerOnTouched(fn: () => void): void { - this._onTouch = fn; - } - - public writeValue(value?: ITransferCallNodeValue | null): void { - if (value) this.form.patchValue(value, { emitEvent: false }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-voicemail-form/flow-node-voicemail-form.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node-voicemail-form/flow-node-voicemail-form.ts deleted file mode 100644 index 010f9479..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node-voicemail-form/flow-node-voicemail-form.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Component, DestroyRef, forwardRef, inject, OnInit } from '@angular/core'; -import { MatFormField } from '@angular/material/form-field'; -import { MatInput } from '@angular/material/input'; -import { MatOption, MatSelect } from '@angular/material/select'; -import { - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - NG_VALUE_ACCESSOR, - ReactiveFormsModule, -} from '@angular/forms'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { INodeSelectOption, IVoiceMailNodeValue } from '../../../domain'; - -interface VoicemailForm { - greeting: FormControl; - maxDuration: FormControl; -} - -@Component({ - selector: 'flow-node-voicemail-form', - templateUrl: './flow-node-voicemail-form.html', - standalone: true, - imports: [MatFormField, MatInput, MatSelect, MatOption, ReactiveFormsModule], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlowNodeVoicemailForm), - multi: true, - }, - ], -}) -export class FlowNodeVoicemailForm implements OnInit, ControlValueAccessor { - private readonly _fb = inject(FormBuilder); - private readonly _destroyRef = inject(DestroyRef); - - private _onChange: (value: Partial) => void = () => {}; - private _onTouch: () => void = () => {}; - - protected form!: FormGroup; - - protected readonly durations: readonly INodeSelectOption[] = [ - { key: 30, value: '30 sec' }, - { key: 60, value: '1 min' }, - { key: 120, value: '2 min' }, - { key: 300, value: '5 min' }, - ]; - - public ngOnInit(): void { - this.form = this._fb.group({ - greeting: this._fb.control('Please leave a message after the beep.'), - maxDuration: this._fb.control(120), - }); - this.form.valueChanges.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((v) => { - this._onChange?.(v); - this._onTouch?.(); - }); - } - - public registerOnChange(fn: (value: Partial) => void): void { - this._onChange = fn; - } - - public registerOnTouched(fn: () => void): void { - this._onTouch = fn; - } - - public writeValue(value?: IVoiceMailNodeValue | null): void { - if (value) this.form.patchValue(value, { emitEvent: false }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node.html b/apps/example-apps/call-center/src/app/components/flow-node/flow-node.html deleted file mode 100644 index 16eaf70d..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node.html +++ /dev/null @@ -1,38 +0,0 @@ -@let model = viewModel(); - - -@if (expanded()) { -
- @switch (model.type) { @case (nodeType.USER_INPUT) { - - } @case (nodeType.PLAY_TEXT) { - - } @case (nodeType.CHECK_SCHEDULE) { - - } @case (nodeType.QUEUE_CALL) { - - } @case (nodeType.TO_OPERATOR) { - - } @case (nodeType.TRANSFER_CALL) { - - } @case (nodeType.VOICE_MAIL) { - - } } -
- - -} @else { - -} diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node.scss b/apps/example-apps/call-center/src/app/components/flow-node/flow-node.scss deleted file mode 100644 index 3f05f158..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node.scss +++ /dev/null @@ -1,2 +0,0 @@ -// Styles for .node-form, .form-row, .mat-mdc-checkbox are in global _material.scss -// (needed to penetrate child form components under Angular Emulated encapsulation) diff --git a/apps/example-apps/call-center/src/app/components/flow-node/flow-node.ts b/apps/example-apps/call-center/src/app/components/flow-node/flow-node.ts deleted file mode 100644 index 3fb9a0f5..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-node/flow-node.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - Component, - DestroyRef, - effect, - inject, - Injector, - input, - OnInit, - output, - signal, - untracked, -} from '@angular/core'; -import { FFlowModule } from '@foblex/flow'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { FlowNodeValuePatch, FlowStateNode, NODE_PARAMS_MAP, NodeType } from '../../domain'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FlowNodeHeader } from './flow-node-header/flow-node-header'; -import { FlowNodeFooterOutputs } from './flow-node-footer-outputs/flow-node-footer-outputs'; -import { FlowNodeBodyOutputs } from './flow-node-body-outputs/flow-node-body-outputs'; -import { distinctUntilChanged } from 'rxjs'; -import { FlowStore } from '../../store/flow-store'; -import { FlowNodeIvrForm } from './flow-node-ivr-form/flow-node-ivr-form'; -import { FlowNodePlayTextForm } from './flow-node-play-text-form/flow-node-play-text-form'; -import { FlowNodeScheduleForm } from './flow-node-schedule-form/flow-node-schedule-form'; -import { FlowNodeQueueForm } from './flow-node-queue-form/flow-node-queue-form'; -import { FlowNodeOperatorForm } from './flow-node-operator-form/flow-node-operator-form'; -import { FlowNodeTransferForm } from './flow-node-transfer-form/flow-node-transfer-form'; -import { FlowNodeVoicemailForm } from './flow-node-voicemail-form/flow-node-voicemail-form'; - -@Component({ - selector: 'flow-node', - templateUrl: './flow-node.html', - styleUrls: ['./flow-node.scss'], - standalone: true, - imports: [ - FFlowModule, - ReactiveFormsModule, - FlowNodeHeader, - FlowNodeFooterOutputs, - FlowNodeBodyOutputs, - FlowNodeIvrForm, - FlowNodePlayTextForm, - FlowNodeScheduleForm, - FlowNodeQueueForm, - FlowNodeOperatorForm, - FlowNodeTransferForm, - FlowNodeVoicemailForm, - ], - host: { - '[class.invalid]': 'form.invalid && form.touched', - }, -}) -export class FlowNode implements OnInit { - private readonly _destroyRef = inject(DestroyRef); - private readonly _injector = inject(Injector); - private readonly _store = inject(FlowStore); - - public readonly viewModel = input.required(); - - public readonly removeConnection = output(); - - protected readonly form = new FormControl(null); - protected readonly defaultParams = NODE_PARAMS_MAP; - protected readonly expanded = signal(false); - - protected readonly nodeType = NodeType; - - public ngOnInit(): void { - this._listenViewModelChanges(); - this._listenToFormChanges(); - } - - private _listenToFormChanges(): void { - this.form.valueChanges - .pipe( - distinctUntilChanged((prev, curr) => this._isEqual(prev, curr)), - takeUntilDestroyed(this._destroyRef), - ) - .subscribe((value) => { - this._store.updateNode(this.viewModel().id, { value }); - }); - } - - private _isEqual(a: TValue, b: TValue): boolean { - return JSON.stringify(a) === JSON.stringify(b); - } - - private _listenViewModelChanges(): void { - effect( - () => { - const viewModel = this.viewModel(); - untracked(() => { - this.expanded.set(viewModel.isExpanded ?? false); - this.form.setValue(viewModel.value, { emitEvent: false }); - }); - }, - { injector: this._injector }, - ); - } - - protected toggle(isExpanded: boolean): void { - this._store.updateNode(this.viewModel().id, { isExpanded }); - } -} diff --git a/apps/example-apps/call-center/src/app/components/flow-palette/flow-palette.ts b/apps/example-apps/call-center/src/app/components/flow-palette/flow-palette.ts deleted file mode 100644 index b9f49137..00000000 --- a/apps/example-apps/call-center/src/app/components/flow-palette/flow-palette.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { FFlowModule } from '@foblex/flow'; -import { MatTooltip } from '@angular/material/tooltip'; -import { NODE_PARAMS_MAP, NodeType } from '../../domain'; -import { MatIcon } from '@angular/material/icon'; -import { MatIconButton } from '@angular/material/button'; -import { FlowStore } from '../../store/flow-store'; - -@Component({ - selector: 'flow-palette', - templateUrl: './flow-palette.html', - styleUrls: ['./flow-palette.scss'], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FFlowModule, MatTooltip, MatIcon, MatIconButton], -}) -export class FlowPalette { - private readonly _store = inject(FlowStore); - - private readonly _allItems = Object.entries(NODE_PARAMS_MAP).map(([key, data]) => ({ - ...data, - type: key, - })); - - protected readonly nodes = computed(() => { - const storeNodes = this._store.nodes(); - const hasIncomingCall = storeNodes.some((n) => n.type === NodeType.INCOMING_CALL); - - return this._allItems.map((item) => ({ - ...item, - disabled: item.type === NodeType.INCOMING_CALL && hasIncomingCall, - })); - }); -} diff --git a/apps/example-apps/call-center/src/app/components/flow/flow.html b/apps/example-apps/call-center/src/app/components/flow/flow.html deleted file mode 100644 index 745b34cf..00000000 --- a/apps/example-apps/call-center/src/app/components/flow/flow.html +++ /dev/null @@ -1,57 +0,0 @@ -@let model = viewModel(); @if (model) { - - - - - - - - - - - @for (connection of connections(); track connection.id) { - - - - } - - - - @for (node of nodes(); track node.id) { - - } - - - -} diff --git a/apps/example-apps/call-center/src/app/components/flow/flow.ts b/apps/example-apps/call-center/src/app/components/flow/flow.ts deleted file mode 100644 index b79974e4..00000000 --- a/apps/example-apps/call-center/src/app/components/flow/flow.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - input, - OnInit, - viewChild, -} from '@angular/core'; -import { - EFMarkerType, - EFReflowCollision, - EFReflowDeltaSource, - FCanvasChangeEvent, - FCanvasComponent, - FCreateConnectionEvent, - FCreateNodeEvent, - FFlowComponent, - FFlowModule, - FMoveNodesEvent, - FReassignConnectionEvent, - FSelectionChangeEvent, - FZoomDirective, - provideFFlow, - withReflowOnResize, -} from '@foblex/flow'; -import { FormsModule } from '@angular/forms'; -import { FlowNode } from '../flow-node/flow-node'; -import { FlowActionPanel } from '../flow-action-panel/flow-action-panel'; -import { FlowPalette } from '../flow-palette/flow-palette'; -import { FlowActionPanelAction } from '../flow-action-panel/flow-action-panel-action'; -import { A, BACKSPACE, DASH, DELETE, NUMPAD_MINUS, NUMPAD_PLUS } from '@angular/cdk/keycodes'; -import { EOperationSystem, PlatformService } from '@foblex/platform'; -import { FlowStore } from '../../store/flow-store'; - -@Component({ - selector: 'flow', - templateUrl: './flow.html', - styleUrls: ['./flow.scss'], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FFlowModule, FlowNode, FlowActionPanel, FlowPalette, FormsModule], - providers: [ - FlowStore, - provideFFlow( - { id: 'main' }, - withReflowOnResize({ - collision: EFReflowCollision.STOP, - deltaSource: EFReflowDeltaSource.EDGE_BASED, - spacing: { vertical: 24 }, - maxCascadeDepth: 8, - maxAbsoluteShiftPerPlan: 10000, - }), - ), - ], - host: { - '(keydown)': 'onKeyDown($event)', - 'tabindex': '-1', - }, -}) -export class Flow implements OnInit { - public readonly id = input.required(); - - protected readonly store = inject(FlowStore); - private readonly _platform = inject(PlatformService); - - private readonly _flow = viewChild(FFlowComponent); - private readonly _canvas = viewChild(FCanvasComponent); - private readonly _zoom = viewChild(FZoomDirective); - - private _isChangeAfterLoadedResetAndCenter = true; - - protected readonly fCanvasChangeEventDebounce = 200; - - protected readonly viewModel = computed(() => this.store.model()); - - protected readonly nodes = this.store.nodes; - protected readonly connections = this.store.connections; - - protected eMarkerType = EFMarkerType; - - public ngOnInit(): void { - this.store.initialize(this.id()); - this._applySelection(); - } - - private _applySelection(): void { - const model = this.store.model(); - if (model?.selection) { - this._flow()?.select(model.selection.nodes || [], model.selection.connections || [], false); - } - } - - protected reset(): void { - this._flow()?.reset(); - this.store.resetFlow(); - this._isChangeAfterLoadedResetAndCenter = true; - this.store.initialize(this.id()); - } - - protected editorLoaded(): void { - this._isChangeAfterLoadedResetAndCenter = true; - if (this.viewModel()?.transform?.position) { - return; - } - this._canvas()?.fitToScreen({ x: 150, y: 150 }, false); - } - - protected changeCanvasTransform(event: FCanvasChangeEvent): void { - if (this._isChangeAfterLoadedResetAndCenter) { - this._isChangeAfterLoadedResetAndCenter = false; - - return; - } - this.store.transformCanvas(event); - } - - protected createNode(event: FCreateNodeEvent): void { - this.store.createNode(event); - } - - protected moveNodes(event: FMoveNodesEvent): void { - this.store.moveNodes(event); - } - - protected createConnection(event: FCreateConnectionEvent): void { - this.store.createConnection(event); - } - - protected reassignConnection(event: FReassignConnectionEvent): void { - this.store.reassignConnection(event); - } - - protected changeSelection(event: FSelectionChangeEvent): void { - this.store.changeSelection(event); - this._applySelection(); - } - - protected removeConnection(outputId: string): void { - this.store.removeConnectionByOutput(outputId); - } - - private _selectAll(): void { - this._flow()?.selectAll(); - this.store.selectAll(); - } - - protected processAction(event: FlowActionPanelAction): void { - switch (event) { - case FlowActionPanelAction.SELECT_ALL: - this._selectAll(); - break; - case FlowActionPanelAction.ZOOM_IN: - this._zoom()?.zoomIn(); - break; - case FlowActionPanelAction.ZOOM_OUT: - this._zoom()?.zoomOut(); - break; - case FlowActionPanelAction.FIT_TO_SCREEN: - this._canvas()?.fitToScreen(); - break; - case FlowActionPanelAction.ONE_TO_ONE: - this._canvas()?.resetScaleAndCenter(); - break; - } - } - - protected onKeyDown(event: KeyboardEvent): void { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { - return; - } - switch (event.keyCode) { - case BACKSPACE: - case DELETE: - this.store.removeSelected(); - break; - case NUMPAD_PLUS: - if (this._isCommandButton(event)) { - this._zoom()?.zoomIn(); - } - break; - case NUMPAD_MINUS: - case DASH: - if (this._isCommandButton(event)) { - this._zoom()?.zoomOut(); - } - break; - case A: - if (this._isCommandButton(event)) { - this._selectAll(); - } - break; - } - } - - private _isCommandButton(event: { metaKey: boolean; ctrlKey: boolean }): boolean { - return this._platform.getOS() === EOperationSystem.MAC_OS ? event.metaKey : event.ctrlKey; - } -} diff --git a/apps/example-apps/call-center/src/app/domain/call-center-connection.ts b/apps/example-apps/call-center/src/app/domain/call-center-connection.ts new file mode 100644 index 00000000..2ad5dc77 --- /dev/null +++ b/apps/example-apps/call-center/src/app/domain/call-center-connection.ts @@ -0,0 +1,3 @@ +import { IFStateConnection } from '@foblex/flow'; + +export type CallCenterConnectionRecord = IFStateConnection; diff --git a/apps/example-apps/call-center/src/app/domain/call-center-flow-snapshot.ts b/apps/example-apps/call-center/src/app/domain/call-center-flow-snapshot.ts new file mode 100644 index 00000000..b62b748c --- /dev/null +++ b/apps/example-apps/call-center/src/app/domain/call-center-flow-snapshot.ts @@ -0,0 +1,5 @@ +import { IFStateData } from '@foblex/flow'; +import { CallCenterConnectionRecord } from './call-center-connection'; +import { CallCenterNodeRecord } from './i-call-center-node'; + +export type CallCenterFlowSnapshot = IFStateData; diff --git a/apps/example-apps/call-center/src/app/domain/call-center-node-factory.ts b/apps/example-apps/call-center/src/app/domain/call-center-node-factory.ts new file mode 100644 index 00000000..e2d59d6a --- /dev/null +++ b/apps/example-apps/call-center/src/app/domain/call-center-node-factory.ts @@ -0,0 +1,164 @@ +import { IPoint } from '@foblex/2d'; +import { generateGuid } from '@foblex/utils'; +import { ECallCenterNodeType } from './e-call-center-node-type'; +import { CallCenterNodeRecord, ICallCenterNode } from './i-call-center-node'; + +export function createCallCenterNode( + type: ECallCenterNodeType, + position: IPoint, +): CallCenterNodeRecord { + switch (type) { + case ECallCenterNodeType.INCOMING_CALL: + return createIncomingCallNode(position); + case ECallCenterNodeType.CHECK_SCHEDULE: + return createCheckScheduleNode(position); + case ECallCenterNodeType.PLAY_TEXT: + return createPlayTextNode(position); + case ECallCenterNodeType.USER_INPUT: + return createIvrNode(position); + case ECallCenterNodeType.QUEUE_CALL: + return createQueueCallNode(position); + case ECallCenterNodeType.TO_OPERATOR: + return createOperatorNode(position); + case ECallCenterNodeType.TRANSFER_CALL: + return createTransferCallNode(position); + case ECallCenterNodeType.VOICEMAIL: + return createVoicemailNode(position); + case ECallCenterNodeType.DISCONNECT: + return createDisconnectNode(position); + } +} + +export function createIncomingCallNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + outputs: [{ id: generateGuid(), label: '' }], + position, + type: ECallCenterNodeType.INCOMING_CALL, + value: null, + }; +} + +export function createCheckScheduleNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + description: 'Business hours check', + input: generateGuid(), + outputs: [ + { id: generateGuid(), label: 'Working hours' }, + { id: generateGuid(), label: 'After hours' }, + ], + position, + type: ECallCenterNodeType.CHECK_SCHEDULE, + value: { startTime: '09:00', endTime: '18:00', timezone: 'UTC', weekends: false }, + }; +} + +export function createPlayTextNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + description: 'Text-to-speech', + input: generateGuid(), + outputs: [{ id: generateGuid(), label: 'Done' }], + position, + type: ECallCenterNodeType.PLAY_TEXT, + value: { text: '' }, + }; +} + +export function createIvrNode(position: IPoint): ICallCenterNode { + return { + id: generateGuid(), + description: 'Digit input', + input: generateGuid(), + outputs: [ + { id: generateGuid(), label: 'Key 1' }, + { id: generateGuid(), label: 'Key 2' }, + { id: generateGuid(), label: 'Key 3' }, + ], + position, + type: ECallCenterNodeType.USER_INPUT, + value: { outputs: 3, timeout: 5 }, + }; +} + +export function createQueueCallNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + description: 'Call queue', + input: generateGuid(), + outputs: [ + { id: generateGuid(), label: 'Answered' }, + { id: generateGuid(), label: 'Timeout' }, + ], + position, + type: ECallCenterNodeType.QUEUE_CALL, + value: { queueName: 'default', maxWaitTime: 300, priority: 'normal', musicOnHold: true }, + }; +} + +export function createOperatorNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + description: 'Operator transfer', + input: generateGuid(), + outputs: [{ id: generateGuid(), label: 'Call ended' }], + position, + type: ECallCenterNodeType.TO_OPERATOR, + value: { department: 'general', skillLevel: 'any' }, + }; +} + +export function createTransferCallNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + description: 'External transfer', + input: generateGuid(), + outputs: [ + { id: generateGuid(), label: 'Answered' }, + { id: generateGuid(), label: 'No answer' }, + ], + position, + type: ECallCenterNodeType.TRANSFER_CALL, + value: { phoneNumber: '', ringTimeout: 30 }, + }; +} + +export function createVoicemailNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + description: 'Record voicemail', + input: generateGuid(), + outputs: [{ id: generateGuid(), label: 'Recorded' }], + position, + type: ECallCenterNodeType.VOICEMAIL, + value: { greeting: 'Please leave a message after the beep.', maxDuration: 120 }, + }; +} + +export function createDisconnectNode( + position: IPoint, +): ICallCenterNode { + return { + id: generateGuid(), + input: generateGuid(), + outputs: [], + position, + type: ECallCenterNodeType.DISCONNECT, + value: null, + }; +} diff --git a/apps/example-apps/call-center/src/app/domain/node-params-map.ts b/apps/example-apps/call-center/src/app/domain/call-center-node-metadata.ts similarity index 67% rename from apps/example-apps/call-center/src/app/domain/node-params-map.ts rename to apps/example-apps/call-center/src/app/domain/call-center-node-metadata.ts index 0f409077..cf05b566 100644 --- a/apps/example-apps/call-center/src/app/domain/node-params-map.ts +++ b/apps/example-apps/call-center/src/app/domain/call-center-node-metadata.ts @@ -1,62 +1,62 @@ -import { NodeType } from './node-type'; +import { ECallCenterNodeType } from './e-call-center-node-type'; -interface INodeParams { +export interface ICallCenterNodeMetadata { name: string; icon: string; color: string; isExpandable: boolean; } -export const NODE_PARAMS_MAP: Record = { - [NodeType.INCOMING_CALL]: { +export const CALL_CENTER_NODE_METADATA: Record = { + [ECallCenterNodeType.INCOMING_CALL]: { name: 'Incoming call', icon: 'ring_volume', color: 'var(--node-color-incoming)', isExpandable: false, }, - [NodeType.CHECK_SCHEDULE]: { + [ECallCenterNodeType.CHECK_SCHEDULE]: { name: 'Check schedule', icon: 'schedule', color: 'var(--node-color-schedule)', isExpandable: true, }, - [NodeType.PLAY_TEXT]: { + [ECallCenterNodeType.PLAY_TEXT]: { name: 'Play text', icon: 'record_voice_over', color: 'var(--node-color-playtext)', isExpandable: true, }, - [NodeType.USER_INPUT]: { + [ECallCenterNodeType.USER_INPUT]: { name: 'IVR', icon: 'dialpad', color: 'var(--node-color-userinput)', isExpandable: true, }, - [NodeType.QUEUE_CALL]: { + [ECallCenterNodeType.QUEUE_CALL]: { name: 'Queue', icon: 'group', color: 'var(--node-color-queue)', isExpandable: true, }, - [NodeType.TO_OPERATOR]: { + [ECallCenterNodeType.TO_OPERATOR]: { name: 'To operator', icon: 'support_agent', color: 'var(--node-color-operator)', isExpandable: true, }, - [NodeType.TRANSFER_CALL]: { + [ECallCenterNodeType.TRANSFER_CALL]: { name: 'Transfer', icon: 'phone_forwarded', color: 'var(--node-color-transfer)', isExpandable: true, }, - [NodeType.VOICE_MAIL]: { + [ECallCenterNodeType.VOICEMAIL]: { name: 'Voicemail', icon: 'voicemail', color: 'var(--node-color-voicemail)', isExpandable: true, }, - [NodeType.DISCONNECT]: { + [ECallCenterNodeType.DISCONNECT]: { name: 'Disconnect', icon: 'call_end', color: 'var(--node-color-disconnect)', diff --git a/apps/example-apps/call-center/src/app/domain/node-value.ts b/apps/example-apps/call-center/src/app/domain/call-center-node-value.ts similarity index 51% rename from apps/example-apps/call-center/src/app/domain/node-value.ts rename to apps/example-apps/call-center/src/app/domain/call-center-node-value.ts index 72547e40..c297afbe 100644 --- a/apps/example-apps/call-center/src/app/domain/node-value.ts +++ b/apps/example-apps/call-center/src/app/domain/call-center-node-value.ts @@ -1,4 +1,4 @@ -import { NodeType } from './node-type'; +import { ECallCenterNodeType } from './e-call-center-node-type'; export type OperatorDepartment = 'general' | 'sales' | 'support' | 'billing' | 'complaints'; export type OperatorSkillLevel = 'any' | 'junior' | 'senior' | 'manager'; @@ -37,28 +37,29 @@ export interface ITransferCallNodeValue { ringTimeout: number | null; } -export interface IVoiceMailNodeValue { +export interface IVoicemailNodeValue { greeting: string | null; maxDuration: number | null; } -export interface IFlowNodeValueMap { - [NodeType.INCOMING_CALL]: null; - [NodeType.CHECK_SCHEDULE]: ICheckScheduleNodeValue; - [NodeType.PLAY_TEXT]: IPlayTextNodeValue; - [NodeType.USER_INPUT]: IUserInputNodeValue; - [NodeType.QUEUE_CALL]: IQueueCallNodeValue; - [NodeType.TO_OPERATOR]: IOperatorNodeValue; - [NodeType.TRANSFER_CALL]: ITransferCallNodeValue; - [NodeType.VOICE_MAIL]: IVoiceMailNodeValue; - [NodeType.DISCONNECT]: null; +export interface ICallCenterNodeValueMap { + [ECallCenterNodeType.INCOMING_CALL]: null; + [ECallCenterNodeType.CHECK_SCHEDULE]: ICheckScheduleNodeValue; + [ECallCenterNodeType.PLAY_TEXT]: IPlayTextNodeValue; + [ECallCenterNodeType.USER_INPUT]: IUserInputNodeValue; + [ECallCenterNodeType.QUEUE_CALL]: IQueueCallNodeValue; + [ECallCenterNodeType.TO_OPERATOR]: IOperatorNodeValue; + [ECallCenterNodeType.TRANSFER_CALL]: ITransferCallNodeValue; + [ECallCenterNodeType.VOICEMAIL]: IVoicemailNodeValue; + [ECallCenterNodeType.DISCONNECT]: null; } -export type IFlowNodeValueByType = IFlowNodeValueMap[TType]; -export type FlowNodeValue = IFlowNodeValueMap[NodeType]; -export type FlowNodeValuePatch = Partial> | null; +export type CallCenterNodeValueByType = + ICallCenterNodeValueMap[TType]; +export type CallCenterNodeValue = ICallCenterNodeValueMap[ECallCenterNodeType]; +export type CallCenterNodeValuePatch = Partial> | null; -export interface INodeSelectOption { +export interface ISelectOption { key: TKey; value: string; } diff --git a/apps/example-apps/call-center/src/app/domain/create-default-call-center-flow.ts b/apps/example-apps/call-center/src/app/domain/create-default-call-center-flow.ts new file mode 100644 index 00000000..b9908f98 --- /dev/null +++ b/apps/example-apps/call-center/src/app/domain/create-default-call-center-flow.ts @@ -0,0 +1,71 @@ +import { PointExtensions } from '@foblex/2d'; +import { generateGuid } from '@foblex/utils'; +import { CallCenterConnectionRecord } from './call-center-connection'; +import { CallCenterFlowSnapshot } from './call-center-flow-snapshot'; +import { + createCheckScheduleNode, + createDisconnectNode, + createIncomingCallNode, + createIvrNode, + createOperatorNode, + createPlayTextNode, + createQueueCallNode, + createVoicemailNode, +} from './call-center-node-factory'; +import { CallCenterNodeRecord } from './i-call-center-node'; + +export function createDefaultCallCenterFlow(): CallCenterFlowSnapshot { + const incomingCallNode = createIncomingCallNode(PointExtensions.initialize(0, 0)); + const checkScheduleNode = createCheckScheduleNode(PointExtensions.initialize(0, 100)); + const playTextNode = createPlayTextNode(PointExtensions.initialize(200, 250)); + const ivrNode = createIvrNode(PointExtensions.initialize(200, 400)); + const queueNode = createQueueCallNode(PointExtensions.initialize(200, 550)); + const operatorNode = createOperatorNode(PointExtensions.initialize(200, 700)); + const voicemailNode = createVoicemailNode(PointExtensions.initialize(-200, 250)); + const disconnectNode = createDisconnectNode(PointExtensions.initialize(-100, 700)); + const connections: CallCenterConnectionRecord[] = []; + + _connect(connections, incomingCallNode.outputs[0].id, _getInputId(checkScheduleNode)); + _connect(connections, checkScheduleNode.outputs[0].id, _getInputId(playTextNode)); + _connect(connections, checkScheduleNode.outputs[1].id, _getInputId(voicemailNode)); + _connect(connections, playTextNode.outputs[0].id, _getInputId(ivrNode)); + _connect(connections, ivrNode.outputs[0].id, _getInputId(queueNode)); + _connect(connections, queueNode.outputs[0].id, _getInputId(operatorNode)); + _connect(connections, queueNode.outputs[1].id, _getInputId(disconnectNode)); + _connect(connections, voicemailNode.outputs[0].id, _getInputId(disconnectNode)); + + playTextNode.value = { + text: 'Welcome to our support line. Press 1 for sales, press 2 for support, press 3 for billing.', + }; + + return { + nodes: [ + incomingCallNode, + checkScheduleNode, + playTextNode, + ivrNode, + queueNode, + operatorNode, + voicemailNode, + disconnectNode, + ], + groups: [], + connections, + }; +} + +function _connect( + connections: CallCenterConnectionRecord[], + sourceId: string, + targetId: string, +): void { + connections.push({ id: generateGuid(), sourceId, targetId }); +} + +function _getInputId(node: CallCenterNodeRecord): string { + if (!node.input) { + throw new Error(`Node "${node.type}" does not expose an input connector.`); + } + + return node.input; +} diff --git a/apps/example-apps/call-center/src/app/domain/e-call-center-node-type.ts b/apps/example-apps/call-center/src/app/domain/e-call-center-node-type.ts new file mode 100644 index 00000000..951bec29 --- /dev/null +++ b/apps/example-apps/call-center/src/app/domain/e-call-center-node-type.ts @@ -0,0 +1,17 @@ +export enum ECallCenterNodeType { + INCOMING_CALL = 'incoming-call', + PLAY_TEXT = 'play-text', + USER_INPUT = 'user-input', + TO_OPERATOR = 'to-operator', + DISCONNECT = 'disconnect', + CHECK_SCHEDULE = 'check-schedule', + QUEUE_CALL = 'queue-call', + VOICEMAIL = 'voice-mail', + TRANSFER_CALL = 'transfer-call', +} + +const CALL_CENTER_NODE_TYPES = new Set(Object.values(ECallCenterNodeType)); + +export function isCallCenterNodeType(value: unknown): value is ECallCenterNodeType { + return typeof value === 'string' && CALL_CENTER_NODE_TYPES.has(value); +} diff --git a/apps/example-apps/call-center/src/app/domain/i-call-center-node.ts b/apps/example-apps/call-center/src/app/domain/i-call-center-node.ts new file mode 100644 index 00000000..756fdffb --- /dev/null +++ b/apps/example-apps/call-center/src/app/domain/i-call-center-node.ts @@ -0,0 +1,23 @@ +import { IFStateNode } from '@foblex/flow'; +import { CallCenterNodeValueByType } from './call-center-node-value'; +import { ECallCenterNodeType } from './e-call-center-node-type'; + +export interface ICallCenterNode< + TType extends ECallCenterNodeType = ECallCenterNodeType, +> extends IFStateNode { + description?: string; + isExpanded?: boolean; + outputs: ICallCenterNodeOutput[]; + input?: string; + type: TType; + value: CallCenterNodeValueByType; +} + +export interface ICallCenterNodeOutput { + id: string; + label: string; +} + +export type CallCenterNodeRecord = { + [TType in ECallCenterNodeType]: ICallCenterNode; +}[ECallCenterNodeType]; diff --git a/apps/example-apps/call-center/src/app/domain/i-flow-state-connection.ts b/apps/example-apps/call-center/src/app/domain/i-flow-state-connection.ts deleted file mode 100644 index a74d2fd3..00000000 --- a/apps/example-apps/call-center/src/app/domain/i-flow-state-connection.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IFlowStateConnection { - id: string; - source: string; - target: string; -} diff --git a/apps/example-apps/call-center/src/app/domain/i-flow-state-node.ts b/apps/example-apps/call-center/src/app/domain/i-flow-state-node.ts deleted file mode 100644 index 5bc97faf..00000000 --- a/apps/example-apps/call-center/src/app/domain/i-flow-state-node.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IPoint } from '@foblex/2d'; -import { NodeType } from './node-type'; -import { FlowNodeValuePatch, IFlowNodeValueByType } from './node-value'; - -export interface IFlowStateNode { - id: string; - description?: string; - isExpanded?: boolean; - outputs: INodeOutput[]; - input?: string; - position: IPoint; - type: TType; - value: IFlowNodeValueByType; -} - -export interface INodeOutput { - id: string; - label: string; -} - -export type FlowStateNode = { - [TType in NodeType]: IFlowStateNode; -}[NodeType]; - -export type FlowStateNodePatch = Partial< - Pick -> & { - value?: FlowNodeValuePatch; -}; diff --git a/apps/example-apps/call-center/src/app/domain/i-flow-state.ts b/apps/example-apps/call-center/src/app/domain/i-flow-state.ts deleted file mode 100644 index 60ca2e41..00000000 --- a/apps/example-apps/call-center/src/app/domain/i-flow-state.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { FlowStateNode } from './i-flow-state-node'; -import { IPoint } from '@foblex/2d'; -import { IFlowStateConnection } from './i-flow-state-connection'; - -export interface IFlowState { - nodes: Record; - connections: Record; - selection?: IFlowStateSelection; - transform?: { - position: IPoint; - scale: number; - }; -} - -export interface IFlowStateSelection { - nodes: string[]; - connections: string[]; -} diff --git a/apps/example-apps/call-center/src/app/domain/index.ts b/apps/example-apps/call-center/src/app/domain/index.ts index d137bfb7..fd7088db 100644 --- a/apps/example-apps/call-center/src/app/domain/index.ts +++ b/apps/example-apps/call-center/src/app/domain/index.ts @@ -1,37 +1,35 @@ -export { NodeType } from './node-type'; -export { NODE_PARAMS_MAP } from './node-params-map'; +export { ECallCenterNodeType, isCallCenterNodeType } from './e-call-center-node-type'; +export { CALL_CENTER_NODE_METADATA, ICallCenterNodeMetadata } from './call-center-node-metadata'; +export { CallCenterNodeRecord, ICallCenterNode, ICallCenterNodeOutput } from './i-call-center-node'; +export { CallCenterConnectionRecord } from './call-center-connection'; +export { CallCenterFlowSnapshot } from './call-center-flow-snapshot'; export { - FlowStateNode, - FlowStateNodePatch, - IFlowStateNode, - INodeOutput, -} from './i-flow-state-node'; -export { IFlowStateConnection } from './i-flow-state-connection'; -export { IFlowState, IFlowStateSelection } from './i-flow-state'; -export { - FlowNodeValue, - FlowNodeValuePatch, + CallCenterNodeValue, + CallCenterNodeValueByType, + CallCenterNodeValuePatch, + ICallCenterNodeValueMap, ICheckScheduleNodeValue, - IFlowNodeValueByType, - INodeSelectOption, IOperatorNodeValue, IPlayTextNodeValue, IQueueCallNodeValue, + ISelectOption, ITransferCallNodeValue, IUserInputNodeValue, - IVoiceMailNodeValue, + IVoicemailNodeValue, OperatorDepartment, OperatorSkillLevel, QueuePriority, -} from './node-value'; +} from './call-center-node-value'; export { + createCallCenterNode, + createCheckScheduleNode, + createDisconnectNode, createIncomingCallNode, - createPlayTextNode, createIvrNode, - createConversationNode, - createDisconnectNode, - createCheckScheduleNode, + createOperatorNode, + createPlayTextNode, createQueueCallNode, createTransferCallNode, - createVoiceMailNode, -} from './node-factory'; + createVoicemailNode, +} from './call-center-node-factory'; +export { createDefaultCallCenterFlow } from './create-default-call-center-flow'; diff --git a/apps/example-apps/call-center/src/app/domain/node-factory.ts b/apps/example-apps/call-center/src/app/domain/node-factory.ts deleted file mode 100644 index 245be456..00000000 --- a/apps/example-apps/call-center/src/app/domain/node-factory.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { IPoint } from '@foblex/2d'; -import { generateGuid } from '@foblex/utils'; -import { NodeType } from './node-type'; -import { IFlowStateNode } from './i-flow-state-node'; - -export function createIncomingCallNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - outputs: [{ id: generateGuid(), label: '' }], - position, - type: NodeType.INCOMING_CALL, - value: null, - }; -} - -export function createCheckScheduleNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - description: 'Business hours check', - input: generateGuid(), - outputs: [ - { id: generateGuid(), label: 'Working hours' }, - { id: generateGuid(), label: 'After hours' }, - ], - position, - type: NodeType.CHECK_SCHEDULE, - value: { startTime: '09:00', endTime: '18:00', timezone: 'UTC', weekends: false }, - }; -} - -export function createPlayTextNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - description: 'Text-to-speech', - input: generateGuid(), - outputs: [{ id: generateGuid(), label: 'Done' }], - position, - type: NodeType.PLAY_TEXT, - value: { text: '' }, - }; -} - -export function createIvrNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - description: 'Digit input', - input: generateGuid(), - outputs: [ - { id: generateGuid(), label: 'Key 1' }, - { id: generateGuid(), label: 'Key 2' }, - { id: generateGuid(), label: 'Key 3' }, - ], - position, - type: NodeType.USER_INPUT, - value: { outputs: 3, timeout: 5 }, - }; -} - -export function createQueueCallNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - description: 'Call queue', - input: generateGuid(), - outputs: [ - { id: generateGuid(), label: 'Answered' }, - { id: generateGuid(), label: 'Timeout' }, - ], - position, - type: NodeType.QUEUE_CALL, - value: { queueName: 'default', maxWaitTime: 300, priority: 'normal', musicOnHold: true }, - }; -} - -export function createConversationNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - description: 'Operator transfer', - input: generateGuid(), - outputs: [{ id: generateGuid(), label: 'Call ended' }], - position, - type: NodeType.TO_OPERATOR, - value: { department: 'general', skillLevel: 'any' }, - }; -} - -export function createTransferCallNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - description: 'External transfer', - input: generateGuid(), - outputs: [ - { id: generateGuid(), label: 'Answered' }, - { id: generateGuid(), label: 'No answer' }, - ], - position, - type: NodeType.TRANSFER_CALL, - value: { phoneNumber: '', ringTimeout: 30 }, - }; -} - -export function createVoiceMailNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - description: 'Record voicemail', - input: generateGuid(), - outputs: [{ id: generateGuid(), label: 'Recorded' }], - position, - type: NodeType.VOICE_MAIL, - value: { greeting: 'Please leave a message after the beep.', maxDuration: 120 }, - }; -} - -export function createDisconnectNode(position: IPoint): IFlowStateNode { - return { - id: generateGuid(), - input: generateGuid(), - outputs: [], - position, - type: NodeType.DISCONNECT, - value: null, - }; -} diff --git a/apps/example-apps/call-center/src/app/domain/node-type.ts b/apps/example-apps/call-center/src/app/domain/node-type.ts deleted file mode 100644 index 24066f4f..00000000 --- a/apps/example-apps/call-center/src/app/domain/node-type.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum NodeType { - INCOMING_CALL = 'incoming-call', - PLAY_TEXT = 'play-text', - USER_INPUT = 'user-input', - TO_OPERATOR = 'to-operator', - DISCONNECT = 'disconnect', - CHECK_SCHEDULE = 'check-schedule', - QUEUE_CALL = 'queue-call', - VOICE_MAIL = 'voice-mail', - TRANSFER_CALL = 'transfer-call', -} diff --git a/apps/example-apps/call-center/src/app/persistence/call-center-flow-storage.ts b/apps/example-apps/call-center/src/app/persistence/call-center-flow-storage.ts new file mode 100644 index 00000000..8a42c2a7 --- /dev/null +++ b/apps/example-apps/call-center/src/app/persistence/call-center-flow-storage.ts @@ -0,0 +1,139 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable } from '@angular/core'; +import { IPoint } from '@foblex/2d'; +import { + CallCenterConnectionRecord, + CallCenterFlowSnapshot, + CallCenterNodeRecord, + ICallCenterNodeOutput, + isCallCenterNodeType, +} from '../domain'; + +interface ILegacyConnection { + id?: unknown; + source?: unknown; + sourceId?: unknown; + target?: unknown; + targetId?: unknown; +} + +const FLOW_STORAGE_KEY_PREFIX = 'flow'; + +@Injectable() +export class CallCenterFlowStorage { + private readonly _document = inject(DOCUMENT); + + public load(flowId: string): CallCenterFlowSnapshot | null { + const raw = this._getStorage()?.getItem(this._getKey(flowId)); + if (!raw) { + return null; + } + + try { + return this._normalizeSnapshot(JSON.parse(raw) as unknown); + } catch { + return null; + } + } + + public save(flowId: string, snapshot: CallCenterFlowSnapshot): void { + this._getStorage()?.setItem(this._getKey(flowId), JSON.stringify(snapshot)); + } + + public clear(flowId: string): void { + this._getStorage()?.removeItem(this._getKey(flowId)); + } + + private _normalizeSnapshot(value: unknown): CallCenterFlowSnapshot | null { + if (!this._isRecord(value)) { + return null; + } + + const nodes = this._toValues(value['nodes']).filter((node): node is CallCenterNodeRecord => + this._isNode(node), + ); + const connections = this._toValues(value['connections']) + .map((connection) => this._normalizeConnection(connection)) + .filter((connection): connection is CallCenterConnectionRecord => connection !== null); + const transform = this._normalizeTransform(value['transform']); + + return { nodes, groups: [], connections, transform }; + } + + private _normalizeConnection(value: unknown): CallCenterConnectionRecord | null { + if (!this._isRecord(value)) { + return null; + } + + const connection = value as ILegacyConnection; + const sourceId = connection.sourceId ?? connection.source; + const targetId = connection.targetId ?? connection.target; + if ( + typeof connection.id !== 'string' || + typeof sourceId !== 'string' || + typeof targetId !== 'string' + ) { + return null; + } + + return { id: connection.id, sourceId, targetId }; + } + + private _normalizeTransform(value: unknown): CallCenterFlowSnapshot['transform'] { + if ( + !this._isRecord(value) || + typeof value['scale'] !== 'number' || + (value['position'] !== undefined && !this._isPoint(value['position'])) + ) { + return undefined; + } + + return { + position: value['position'] as IPoint | undefined, + scale: value['scale'], + }; + } + + private _isNode(value: unknown): boolean { + return ( + this._isRecord(value) && + typeof value['id'] === 'string' && + isCallCenterNodeType(value['type']) && + this._isPoint(value['position']) && + Array.isArray(value['outputs']) && + value['outputs'].every((output) => this._isNodeOutput(output)) + ); + } + + private _isNodeOutput(value: unknown): value is ICallCenterNodeOutput { + return ( + this._isRecord(value) && typeof value['id'] === 'string' && typeof value['label'] === 'string' + ); + } + + private _isPoint(value: unknown): value is IPoint { + return ( + this._isRecord(value) && typeof value['x'] === 'number' && typeof value['y'] === 'number' + ); + } + + private _isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; + } + + private _toValues(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + + return this._isRecord(value) ? Object.values(value) : []; + } + + private _getStorage(): Storage | null { + return this._document.defaultView?.localStorage ?? null; + } + + private _getKey(flowId: string): string { + return FLOW_STORAGE_KEY_PREFIX + flowId; + } +} diff --git a/apps/example-apps/call-center/src/app/services/theme.service.ts b/apps/example-apps/call-center/src/app/services/theme.service.ts index 7ac248a0..022a5025 100644 --- a/apps/example-apps/call-center/src/app/services/theme.service.ts +++ b/apps/example-apps/call-center/src/app/services/theme.service.ts @@ -1,30 +1,29 @@ -import { Injectable } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, signal } from '@angular/core'; + +export type Theme = 'light' | 'dark'; @Injectable({ providedIn: 'root' }) export class ThemeService { + private readonly _document = inject(DOCUMENT); private readonly _storageKey = 'theme'; + private readonly _current = signal('light'); - constructor() { - const saved = localStorage.getItem(this._storageKey) as 'light' | 'dark' | null; - this.setTheme(saved ?? 'light'); - } + public readonly current = this._current.asReadonly(); - public get current(): 'light' | 'dark' { - return (document.documentElement.getAttribute('data-theme') as 'dark' | null) - ? 'dark' - : 'light'; - } - - public toggle(theme: 'light' | 'dark'): void { - this.setTheme(theme); + constructor() { + const saved = this._document.defaultView?.localStorage.getItem(this._storageKey); + this.setTheme(saved === 'dark' ? 'dark' : 'light'); } - public setTheme(theme: 'light' | 'dark'): void { + public setTheme(theme: Theme): void { if (theme === 'dark') { - document.documentElement.setAttribute('data-theme', 'dark'); + this._document.documentElement.setAttribute('data-theme', 'dark'); } else { - document.documentElement.removeAttribute('data-theme'); + this._document.documentElement.removeAttribute('data-theme'); } - localStorage.setItem(this._storageKey, theme); + + this._current.set(theme); + this._document.defaultView?.localStorage.setItem(this._storageKey, theme); } } diff --git a/apps/example-apps/call-center/src/app/state/call-center-flow-state.ts b/apps/example-apps/call-center/src/app/state/call-center-flow-state.ts new file mode 100644 index 00000000..c4e189b7 --- /dev/null +++ b/apps/example-apps/call-center/src/app/state/call-center-flow-state.ts @@ -0,0 +1,193 @@ +import { computed, effect, inject, Injectable, signal, untracked } from '@angular/core'; +import { IPoint } from '@foblex/2d'; +import { FCreateNodeEvent, FFlowState } from '@foblex/flow'; +import { generateGuid } from '@foblex/utils'; +import { + CallCenterConnectionRecord, + CallCenterNodeRecord, + CallCenterNodeValuePatch, + ECallCenterNodeType, + ICallCenterNode, + ICallCenterNodeOutput, + IUserInputNodeValue, + createCallCenterNode, + createDefaultCallCenterFlow, + isCallCenterNodeType, +} from '../domain'; +import { CallCenterFlowStorage } from '../persistence/call-center-flow-storage'; + +const POSITION_EPSILON = 0.001; + +@Injectable() +export class CallCenterFlowState extends FFlowState< + CallCenterNodeRecord, + CallCenterConnectionRecord +> { + private readonly _storage = inject(CallCenterFlowStorage); + private readonly _flowId = signal(''); + + public readonly canDeleteSelection = computed(() => { + const selection = this.selection(); + + return Boolean( + selection.nodeIds.length + selection.groupIds.length + selection.connectionIds.length, + ); + }); + + constructor() { + super(); + effect(() => { + this.changes(); + const flowId = this._flowId(); + if (!flowId) { + return; + } + + this._storage.save( + flowId, + untracked(() => this.snapshot()), + ); + }); + } + + public initialize(flowId: string): void { + this._flowId.set(flowId); + this.load(this._storage.load(flowId) ?? createDefaultCallCenterFlow()); + } + + public reset(): void { + this._storage.clear(this._flowId()); + this.load(createDefaultCallCenterFlow()); + } + + public deleteSelection(): void { + this.applyRemoval(this.selection()); + } + + public removeConnectionFromOutput(outputId: string): void { + const connection = this.connections().find((item) => item.sourceId === outputId); + if (connection) { + this.removeConnections([connection.id]); + } + } + + public async setNodeExpanded(nodeId: string, isExpanded: boolean): Promise { + this.beginBatch(); + + try { + this.updateNode(nodeId, { isExpanded }); + await _afterResizeObserverTurn(); + } finally { + this.endBatch(); + } + } + + public updateNodeValue(nodeId: string, valuePatch: CallCenterNodeValuePatch): void { + const shape = this.currentShape(); + const existing = shape.nodes[nodeId]; + if (!existing) { + return; + } + + const node = { + ...existing, + value: _mergeNodeValue(existing, valuePatch), + } as CallCenterNodeRecord; + let connections = shape.connections; + + if (_isUserInputNode(existing) && _isUserInputValue(valuePatch) && valuePatch.outputs != null) { + const removedOutputs = existing.outputs.slice(valuePatch.outputs); + node.outputs = _resizeOutputs(existing.outputs, valuePatch.outputs); + connections = _removeConnectionsFromOutputs(connections, removedOutputs); + } + + this.commit({ + ...shape, + nodes: { ...shape.nodes, [nodeId]: node }, + connections, + }); + } + + public override moveNodes(positions: { id: string; position: IPoint }[]): void { + const changedPositions = positions.filter(({ id, position }) => { + const item = this.getNode(id) ?? this.getGroup(id); + + return ( + item && + (Math.abs(item.position.x - position.x) >= POSITION_EPSILON || + Math.abs(item.position.y - position.y) >= POSITION_EPSILON) + ); + }); + + super.moveNodes(changedPositions); + } + + protected override createNodeRecord(event: FCreateNodeEvent): CallCenterNodeRecord { + if (!isCallCenterNodeType(event.data)) { + throw new Error(`Unknown call center node type: ${String(event.data)}`); + } + + return createCallCenterNode(event.data, { + x: event.externalItemRect.x, + y: event.externalItemRect.y, + }); + } +} + +function _afterResizeObserverTurn(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +function _isUserInputNode( + node: CallCenterNodeRecord, +): node is ICallCenterNode { + return node.type === ECallCenterNodeType.USER_INPUT; +} + +function _isUserInputValue(value: CallCenterNodeValuePatch): value is Partial { + return value !== null && typeof value === 'object' && 'outputs' in value; +} + +function _mergeNodeValue( + existing: ICallCenterNode, + valuePatch: CallCenterNodeValuePatch, +): ICallCenterNode['value'] { + if (valuePatch === null || existing.value === null) { + return valuePatch as ICallCenterNode['value']; + } + + return { ...existing.value, ...valuePatch } as ICallCenterNode['value']; +} + +function _resizeOutputs(existing: ICallCenterNodeOutput[], count: number): ICallCenterNodeOutput[] { + if (count === existing.length) { + return existing; + } + if (count < existing.length) { + return existing.slice(0, count); + } + + const result = [...existing]; + for (let index = existing.length; index < count; index++) { + result.push({ id: generateGuid(), label: `Key ${index + 1}` }); + } + + return result; +} + +function _removeConnectionsFromOutputs( + connections: Record, + outputs: ICallCenterNodeOutput[], +): Record { + if (!outputs.length) { + return connections; + } + + const removedIds = new Set(outputs.map((output) => output.id)); + + return Object.fromEntries( + Object.entries(connections).filter(([, connection]) => !removedIds.has(connection.sourceId)), + ); +} diff --git a/apps/example-apps/call-center/src/app/state/index.ts b/apps/example-apps/call-center/src/app/state/index.ts new file mode 100644 index 00000000..3fb36792 --- /dev/null +++ b/apps/example-apps/call-center/src/app/state/index.ts @@ -0,0 +1 @@ +export { CallCenterFlowState } from './call-center-flow-state'; diff --git a/apps/example-apps/call-center/src/app/store/flow-store.ts b/apps/example-apps/call-center/src/app/store/flow-store.ts deleted file mode 100644 index 957bb5bb..00000000 --- a/apps/example-apps/call-center/src/app/store/flow-store.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { computed } from '@angular/core'; -import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals'; -import { - FCanvasChangeEvent, - FCreateConnectionEvent, - FCreateNodeEvent, - FMoveNodesEvent, - FReassignConnectionEvent, - FSelectionChangeEvent, -} from '@foblex/flow'; -import { generateGuid } from '@foblex/utils'; -import { IPoint, PointExtensions } from '@foblex/2d'; -import { - FlowStateNode, - FlowStateNodePatch, - FlowNodeValuePatch, - IFlowStateNode, - IFlowState, - IFlowStateConnection, - IUserInputNodeValue, - NodeType, - createIncomingCallNode, - createPlayTextNode, - createIvrNode, - createConversationNode, - createDisconnectNode, - createCheckScheduleNode, - createQueueCallNode, - createTransferCallNode, - createVoiceMailNode, -} from '../domain'; - -interface FlowStoreState { - flowId: string; - model: IFlowState | undefined; -} - -function loadFromStorage(flowId: string): IFlowState | null { - const raw = localStorage.getItem('flow' + flowId); - - return raw ? (JSON.parse(raw) as IFlowState) : null; -} - -function saveToStorage(model: IFlowState, flowId: string): void { - localStorage.setItem('flow' + flowId, JSON.stringify(model)); -} - -function createDefaultState(): IFlowState { - const incomingCallNode = createIncomingCallNode(PointExtensions.initialize(0, 0)); - const checkScheduleNode = createCheckScheduleNode(PointExtensions.initialize(0, 100)); - const playTextNode = createPlayTextNode(PointExtensions.initialize(200, 250)); - const ivrNode = createIvrNode(PointExtensions.initialize(200, 400)); - const queueNode = createQueueCallNode(PointExtensions.initialize(200, 550)); - const operatorNode = createConversationNode(PointExtensions.initialize(200, 700)); - const voicemailNode = createVoiceMailNode(PointExtensions.initialize(-200, 250)); - const disconnectNode = createDisconnectNode(PointExtensions.initialize(-100, 700)); - - const connections: Record = {}; - - function connect(sourceOutputId: string, targetInputId: string): void { - const id = generateGuid(); - connections[id] = { id, source: sourceOutputId, target: targetInputId }; - } - - // Incoming -> CheckSchedule - connect(incomingCallNode.outputs[0].id, getNodeInputId(checkScheduleNode)); - // CheckSchedule (working hours) -> PlayText - connect(checkScheduleNode.outputs[0].id, getNodeInputId(playTextNode)); - // CheckSchedule (after hours) -> Voicemail - connect(checkScheduleNode.outputs[1].id, getNodeInputId(voicemailNode)); - // PlayText -> IVR - connect(playTextNode.outputs[0].id, getNodeInputId(ivrNode)); - // IVR (Key 1) -> Queue - connect(ivrNode.outputs[0].id, getNodeInputId(queueNode)); - // Queue (Answered) -> Operator - connect(queueNode.outputs[0].id, getNodeInputId(operatorNode)); - // Queue (Timeout) -> Disconnect - connect(queueNode.outputs[1].id, getNodeInputId(disconnectNode)); - // Voicemail -> Disconnect - connect(voicemailNode.outputs[0].id, getNodeInputId(disconnectNode)); - - playTextNode.value = { - text: 'Welcome to our support line. Press 1 for sales, press 2 for support, press 3 for billing.', - }; - - return { - nodes: { - [incomingCallNode.id]: incomingCallNode, - [checkScheduleNode.id]: checkScheduleNode, - [playTextNode.id]: playTextNode, - [ivrNode.id]: ivrNode, - [queueNode.id]: queueNode, - [operatorNode.id]: operatorNode, - [voicemailNode.id]: voicemailNode, - [disconnectNode.id]: disconnectNode, - }, - connections, - }; -} - -function getNodeInputId(node: FlowStateNode): string { - if (!node.input) { - throw new Error(`Node "${node.type}" does not expose an input connector.`); - } - - return node.input; -} - -function isUserInputNode(node: FlowStateNode): node is IFlowStateNode { - return node.type === NodeType.USER_INPUT; -} - -function isUserInputValue( - value: FlowStateNodePatch['value'], -): value is Partial { - return value !== null && typeof value === 'object' && 'outputs' in value; -} - -function mergeNodeValue( - existing: IFlowStateNode, - nextValue?: FlowNodeValuePatch, -): IFlowStateNode['value'] { - if (nextValue === undefined) { - return existing.value; - } - - if (existing.value === null || nextValue === null) { - return nextValue as IFlowStateNode['value']; - } - - return { ...existing.value, ...nextValue } as IFlowStateNode['value']; -} - -function createNodeByType(type: string, position: IPoint): FlowStateNode { - switch (type) { - case NodeType.INCOMING_CALL: - return createIncomingCallNode(position); - case NodeType.PLAY_TEXT: - return createPlayTextNode(position); - case NodeType.USER_INPUT: - return createIvrNode(position); - case NodeType.TO_OPERATOR: - return createConversationNode(position); - case NodeType.DISCONNECT: - return createDisconnectNode(position); - case NodeType.CHECK_SCHEDULE: - return createCheckScheduleNode(position); - case NodeType.QUEUE_CALL: - return createQueueCallNode(position); - case NodeType.TRANSFER_CALL: - return createTransferCallNode(position); - case NodeType.VOICE_MAIL: - return createVoiceMailNode(position); - default: - throw new Error('Unknown node type'); - } -} - -function resizeOutputs( - existing: FlowStateNode['outputs'], - count: number, -): FlowStateNode['outputs'] { - if (count === existing.length) return existing; - if (count < existing.length) return existing.slice(0, count); - - const result = [...existing]; - for (let i = existing.length; i < count; i++) { - result.push({ id: generateGuid(), label: `Key ${i + 1}` }); - } - - return result; -} - -function findConnectionsForNode( - node: FlowStateNode, - connections: IFlowStateConnection[], -): string[] { - const result: string[] = []; - result.push(...connections.filter((c) => c.target === node.input).map((c) => c.id)); - const outputIds = node.outputs.map((o) => o.id); - result.push(...connections.filter((c) => outputIds.includes(c.source)).map((c) => c.id)); - - return result; -} - -export const FlowStore = signalStore( - withState({ - flowId: '', - model: undefined, - }), - - withComputed((state) => ({ - nodes: computed(() => Object.values(state.model()?.nodes || {})), - connections: computed(() => Object.values(state.model()?.connections || {})), - canRemove: computed(() => { - const m = state.model(); - if (!m?.selection) return false; - - return m.selection.nodes.length + m.selection.connections.length > 0; - }), - })), - - withMethods((store) => ({ - initialize(flowId: string): void { - const model = loadFromStorage(flowId) || createDefaultState(); - patchState(store, { flowId, model }); - }, - - resetFlow(): void { - localStorage.removeItem('flow' + store.flowId()); - const model = createDefaultState(); - patchState(store, { model }); - }, - - transformCanvas(event: FCanvasChangeEvent): void { - const model = store.model(); - if (!model) return; - const updated = { ...model, transform: { position: event.position, scale: event.scale } }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - createNode(event: FCreateNodeEvent): void { - const model = store.model(); - if (!model) return; - const node = createNodeByType(event.data, event.rect); - const updated: IFlowState = { - ...model, - nodes: { ...model.nodes, [node.id]: node }, - selection: { nodes: [node.id], connections: [] }, - }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - moveNodes(event: FMoveNodesEvent): void { - const model = store.model(); - if (!model) return; - const updatedNodes = { ...model.nodes }; - for (const { id, position } of event.fNodes) { - updatedNodes[id] = { ...updatedNodes[id], position }; - } - const updated = { ...model, nodes: updatedNodes }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - createConnection(event: FCreateConnectionEvent): void { - const model = store.model(); - if (!model || !event.fInputId) return; - const connection: IFlowStateConnection = { - id: generateGuid(), - source: event.fOutputId, - target: event.fInputId, - }; - const updated: IFlowState = { - ...model, - connections: { ...model.connections, [connection.id]: connection }, - selection: { nodes: [], connections: [connection.id] }, - }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - reassignConnection(event: FReassignConnectionEvent): void { - const model = store.model(); - if (!model || !event.newTargetId) return; - const existing = model.connections[event.connectionId]; - if (!existing) return; - const updated: IFlowState = { - ...model, - connections: { - ...model.connections, - [event.connectionId]: { ...existing, target: event.newTargetId }, - }, - selection: { nodes: [], connections: [event.connectionId] }, - }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - changeSelection(event: FSelectionChangeEvent): void { - const model = store.model(); - if (!model) return; - const updated: IFlowState = { - ...model, - selection: { nodes: [...event.fNodeIds], connections: [...event.fConnectionIds] }, - }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - selectAll(): void { - const model = store.model(); - if (!model) return; - const updated: IFlowState = { - ...model, - selection: { - nodes: Object.keys(model.nodes), - connections: Object.keys(model.connections), - }, - }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - removeConnectionByOutput(outputId: string): void { - const model = store.model(); - if (!model) return; - const conn = Object.values(model.connections).find((c) => c.source === outputId); - if (!conn) return; - const { [conn.id]: _, ...remainingConnections } = model.connections; - const updated = { ...model, connections: remainingConnections }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - removeSelected(): void { - const model = store.model(); - if (!model) return; - const selectedNodeIds = model.selection?.nodes ?? []; - const selectedConnIds = model.selection?.connections ?? []; - if (!selectedConnIds.length && !selectedNodeIds.length) return; - - const connIdsToDelete = new Set(selectedConnIds); - const allConnections = Object.values(model.connections); - for (const nodeId of selectedNodeIds) { - const node = model.nodes[nodeId]; - if (!node) continue; - findConnectionsForNode(node, allConnections).forEach((cid) => connIdsToDelete.add(cid)); - } - - const remainingNodes = { ...model.nodes }; - for (const id of selectedNodeIds) { - delete remainingNodes[id]; - } - - const remainingConns = { ...model.connections }; - for (const id of connIdsToDelete) { - delete remainingConns[id]; - } - - const updated: IFlowState = { - ...model, - nodes: remainingNodes, - connections: remainingConns, - selection: undefined, - }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - - updateNode(nodeId: string, value: FlowStateNodePatch): void { - const model = store.model(); - if (!model) return; - const existing = model.nodes[nodeId]; - if (!existing) return; - - const merged = { - ...existing, - ...value, - value: mergeNodeValue(existing, value.value), - } as FlowStateNode; - let connections = model.connections; - - if ( - isUserInputNode(existing) && - isUserInputValue(value.value) && - value.value.outputs != null - ) { - const newCount = value.value.outputs; - const removedOutputs = existing.outputs.slice(newCount); - merged.outputs = resizeOutputs(existing.outputs, newCount); - - if (removedOutputs.length > 0) { - const removedIds = new Set(removedOutputs.map((o) => o.id)); - const filtered: Record = {}; - for (const [id, conn] of Object.entries(connections)) { - if (!removedIds.has(conn.source)) { - filtered[id] = conn; - } - } - connections = filtered; - } - } - - const updated: IFlowState = { - ...model, - nodes: { ...model.nodes, [nodeId]: merged }, - connections, - }; - patchState(store, { model: updated }); - saveToStorage(updated, store.flowId()); - }, - })), -); diff --git a/apps/example-apps/call-center/src/styles/flow/_node.scss b/apps/example-apps/call-center/src/styles/flow/_node.scss index 16a20516..623d640f 100644 --- a/apps/example-apps/call-center/src/styles/flow/_node.scss +++ b/apps/example-apps/call-center/src/styles/flow/_node.scss @@ -39,18 +39,36 @@ } } -.f-node-output { +.flow-output-connector { width: var(--out-size); height: var(--out-size); background-color: var(--out-bg); border-radius: var(--out-radius); border: 1.5px solid var(--out-border); color: var(--out-text); - transition: all var(--transition-fast); + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); display: flex; align-items: center; justify-content: center; + .remove-output-connection { + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + .mat-icon { font-size: 12px; width: 12px; @@ -60,7 +78,7 @@ display: none; } - &.f-node-output-connected { + &.f-connector-connected { cursor: pointer; border-color: var(--out-connected-border); background-color: var(--out-connected-bg); diff --git a/apps/f-flow-portal/public/llms-full.txt b/apps/f-flow-portal/public/llms-full.txt index 4a790cfa..d4d77530 100644 --- a/apps/f-flow-portal/public/llms-full.txt +++ b/apps/f-flow-portal/public/llms-full.txt @@ -24,7 +24,7 @@ It is distributed as the npm package `@foblex/flow` under the MIT license. Compa ## 2. Project facts - **Package**: `@foblex/flow` on npm — https://www.npmjs.com/package/@foblex/flow -- **Current version**: v19.1.0 (July 2026) +- **Current version**: v19.1.2 (July 2026) - **Weekly installs**: ~16K - **GitHub**: https://github.com/Foblex/f-flow - **License**: MIT @@ -866,7 +866,26 @@ Data methods: `load`, `snapshot`, `getNode`, `getGroup`, `getConnection`. Mutation methods: `addNodes`, `updateNode`, `moveNodes`, `removeNodes`, `addGroups`, `updateGroup`, `removeGroups`, `addConnections`, `updateConnection`, `removeConnections`, `removeItems`. -History methods: `undo`, `redo`, `clearHistory`, `batch`. A standalone mutation increments `changes()` when it settles. Mutations inside an outer batch update collection signals immediately but produce one undo entry and one `changes()` increment when the batch closes. The controller keeps one batch open from `fDragStarted` through `fDragEnded`, so selection at drag start plus move/drop at drag end remains one action even across ticks. +History methods: `undo`, `redo`, `clearHistory`, `batch`, `beginBatch`, `endBatch`. A standalone mutation increments `changes()` when it settles. Mutations inside an outer batch update collection signals immediately but produce one undo entry and one `changes()` increment when the batch closes. The controller keeps one batch open from `fDragStarted` through `fDragEnded`, so selection at drag start plus move/drop at drag end remains one action even across ticks. Use `batch(work)` for synchronous mutations and a matched `beginBatch()` / `endBatch()` pair when one logical action crosses asynchronous boundaries. + +#### Expand/collapse plus reflow as one undo item + +With `withFlowState()` and `withReflowOnResize()`, expanding a node changes its record immediately, but reflow positions arrive later: Angular must render the new size, then `ResizeObserver` emits an `fMoveNodes` event. A synchronous `batch(() => ...)` closes before that event and can create a second undo item. + +```typescript +state.beginBatch(); + +try { + state.updateNode(nodeId, { isExpanded }); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} finally { + state.endBatch(); +} +``` + +The managed state controller applies the later reflow `fMoveNodes` update while the transaction is open. The first mutation retains the shape from before the click, so one `undo()` restores both `isExpanded` and all positions moved by reflow; the outer close also produces one `changes()` increment. Two animation frames are suitable for an immediately rendered content-size change. If size settles after a CSS transition, lazy load, or another asynchronous layout stage, close from that stage's real completion signal. Never leave a batch open, or unrelated later mutations will be merged into the same history entry. Full guide: https://flow.foblex.com/examples/state Supported automatic v1 gestures: create/reassign connection, move nodes/groups, delete selection, external-item creation, optional drop-to-group, selection, and canvas pan/zoom. Rotation, connection waypoint editing, and user resize are not captured by managed state v1. Library-driven measured-size/group-auto-fit updates may amend current geometry without creating another user history step. diff --git a/apps/f-flow-portal/public/llms.txt b/apps/f-flow-portal/public/llms.txt index 7519f02d..9f2204c3 100644 --- a/apps/f-flow-portal/public/llms.txt +++ b/apps/f-flow-portal/public/llms.txt @@ -5,7 +5,7 @@ ## Key facts - Package: `@foblex/flow` on npm — https://www.npmjs.com/package/@foblex/flow -- Current version: v19.1.0 (see the npm page for the live number) +- Current version: v19.1.2 (see the npm page for the live number) - Weekly installs: ~16K (verify on npm for the latest number) - GitHub: https://github.com/Foblex/f-flow - License: MIT @@ -78,7 +78,7 @@ - [Click to Connect](https://flow.foblex.com/examples/click-to-connect): Click source, then target — withConnectionFlow('click') - [Accessibility](https://flow.foblex.com/examples/accessibility): Keyboard-operable editor with screen-reader announcements — opt-in via withA11y() -- [Managed Flow State](https://flow.foblex.com/examples/state): Optional `withFlowState()` store with typed records, automatic gesture updates, batching, undo/redo, and snapshots +- [Managed Flow State](https://flow.foblex.com/examples/state): Optional `withFlowState()` store with typed records, automatic gesture updates, batching, undo/redo, snapshots, and an async reflow transaction recipe - [All Examples Overview](https://flow.foblex.com/examples/overview): 50+ interactive examples - [AI Low-Code Platform](https://flow.foblex.com/examples/ai-low-code-platform): Reference app diff --git a/apps/f-flow-portal/public/markdown/examples/editor-state/state.md b/apps/f-flow-portal/public/markdown/examples/editor-state/state.md index d1b67fd6..245612aa 100644 --- a/apps/f-flow-portal/public/markdown/examples/editor-state/state.md +++ b/apps/f-flow-portal/public/markdown/examples/editor-state/state.md @@ -2,7 +2,7 @@ toc: false wideContent: true publishedAt: "2026-07-07" -updatedAt: "2026-07-12" +updatedAt: "2026-07-13" --- # Managed Flow State @@ -116,16 +116,17 @@ The store changes its collection signals immediately while a gesture is in progr ## Core API -| API | Purpose | -| ----------------------------------------- | ---------------------------------------------------------------------------- | -| `nodes`, `groups`, `connections` | Readonly collection signals rendered by the template. | -| `selection`, `transform` | Current selection and canvas transform signals. | -| `canUndo`, `canRedo`, `changes` | History availability and settled-change notification signals. | -| `load(data)`, `snapshot()` | Replace/reset the store and export persistable data. | -| `getNode`, `getGroup`, `getConnection` | Read one record by id. | -| `add*`, `update*`, `remove*`, `moveNodes` | Programmatic immutable mutations. | -| `undo`, `redo`, `clearHistory` | History operations. | -| `batch(work)` | Collapse several mutations into one undo step and one `changes()` increment. | +| API | Purpose | +| ----------------------------------------- | --------------------------------------------------------------------------------- | +| `nodes`, `groups`, `connections` | Readonly collection signals rendered by the template. | +| `selection`, `transform` | Current selection and canvas transform signals. | +| `canUndo`, `canRedo`, `changes` | History availability and settled-change notification signals. | +| `load(data)`, `snapshot()` | Replace/reset the store and export persistable data. | +| `getNode`, `getGroup`, `getConnection` | Read one record by id. | +| `add*`, `update*`, `remove*`, `moveNodes` | Programmatic immutable mutations. | +| `undo`, `redo`, `clearHistory` | History operations. | +| `batch(work)` | Collapse synchronous mutations into one undo step and one `changes()` increment. | +| `beginBatch()`, `endBatch()` | Keep one transaction open across asynchronous work such as content-driven reflow. | ## Selection, and whether it belongs in the history @@ -162,6 +163,42 @@ state.batch(() => { }); ``` +## Keep expand/collapse and async reflow in one undo step + +Expanding a node can produce two state changes that belong to one user action: + +1. The application stores `isExpanded` immediately. +2. Angular renders the larger node, `ResizeObserver` runs `withReflowOnResize`, and the plugin emits the resulting node positions through `fMoveNodes` on a later rendering turn. + +`batch(() => ...)` only covers synchronous work, so it closes before the reflow positions arrive. Use `beginBatch()` and `endBatch()` to keep the same transaction open across the render boundary. The state controller applies the reflow `fMoveNodes` event automatically while that transaction is still open: + +```ts +function afterResizeObserverTurn(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +async function setExpanded(nodeId: string, isExpanded: boolean): Promise { + state.beginBatch(); + + try { + state.updateNode(nodeId, { isExpanded }); + await afterResizeObserverTurn(); + } finally { + state.endBatch(); + } +} +``` + +The first mutation records the shape from before the click. Any positions emitted by reflow amend that same history entry, and the outer `endBatch()` produces one `changes()` tick. One `undo()` therefore restores both the collapsed/expanded value and every node moved by reflow. + +Two animation frames are appropriate when the size change is rendered immediately: the first lets Angular update and measure the DOM, and the second lets the `ResizeObserver`-driven move settle. If the node size depends on a CSS transition, lazy content, or another asynchronous layout step, close the batch from that operation's real completion signal instead. Always pair `beginBatch()` with `endBatch()` in `finally`; leaving a batch open would merge later, unrelated edits into the same history item. + +If the expand/collapse control is nested inside an `fDragHandle`, add `fDragBlocker` to that control. Otherwise the pointer down can select the node before the click handler runs. With `selectionInHistory: true`, that selection is correctly recorded as its own undo item, so the user action appears to require an extra undo even though expand and reflow are already batched together. + +The [Call Center Flow](./examples/call-center) uses this pattern when its embedded node forms expand and collapse. The [Reflow on Resize](./examples/reflow-on-resize) guide explains how the layout move itself is calculated. + ### Connection cascade requires rendered connectors Connections store connector ids (`sourceId`/`targetId`), not owner node ids. While the flow is rendered, the controller resolves each connector to its node/group and `removeNodes`/`removeGroups` automatically remove attached connections. Before connector directives have registered (for example, programmatic editing immediately after `load()` or during SSR), that ownership map does not exist. diff --git a/apps/f-flow-portal/public/markdown/examples/reference-apps/call-center.md b/apps/f-flow-portal/public/markdown/examples/reference-apps/call-center.md index c0af7633..db2af827 100644 --- a/apps/f-flow-portal/public/markdown/examples/reference-apps/call-center.md +++ b/apps/f-flow-portal/public/markdown/examples/reference-apps/call-center.md @@ -2,7 +2,7 @@ toc: false wideContent: true publishedAt: "2026-04-13" -updatedAt: "2026-04-13" +updatedAt: "2026-07-13" --- # Call Center Flow @@ -30,12 +30,26 @@ This example shows how to build a product-style call-routing editor on top of Fo - `f-flow` and `f-canvas` as the workflow surface. - `fZoom` for viewport scaling and pan. - `fExternalItem` and `fCreateNode` for palette-driven node creation. -- `fNode`, `fNodeInput`, and `fNodeOutput` for call-flow steps and connectors. +- `fNode` and the unified `fConnector` API for call-flow steps and connectors. - `fConnection` with segmented routing and `fConnectionMarkerArrow` for directional links. - `fConnectionForCreate` for drag-to-connect interaction. -- `fMoveNodes`, `fCreateConnection`, `fReassignConnection`, and `fSelectionChange` events for synchronizing editor state. +- `withFlowState()` for typed records, automatic gesture updates, undo/redo, and local persistence. +- `withReflowOnResize()` for moving downstream steps when an embedded node form expands or collapses. +- `withA11y()` and the default control scheme for keyboard and pointer interaction. - `fBackground`, `fCirclePattern`, `fLineAlignment`, `fSelectionArea`, and `fMinimap` for canvas usability. +## Application architecture + +`CallCenterFlowState` owns editor history and domain commands, `CallCenterFlowStorage` owns local persistence and legacy snapshot normalization, and domain factories construct valid node records and the default call flow. UI components depend on those responsibilities instead of duplicating state, persistence, or record-construction logic. + +## Expand, reflow, and undo as one action + +An expand/collapse click updates the node record synchronously, while reflow positions arrive later after Angular rendering and `ResizeObserver`. The example opens `FFlowState.beginBatch()` before storing `isExpanded` and closes it after the resize-driven render turn. Reflow's `fMoveNodes` event is therefore committed into the same history item, so one undo restores both the node's content state and every shifted node position. + +The toggle also carries `fDragBlocker` because it is rendered inside the node's `fDragHandle`. This keeps the button click from producing a separate selection history item before the expand/reflow transaction. + +See the complete [async reflow transaction recipe](./examples/state), including why synchronous `batch()` is insufficient and when two animation frames should be replaced by a real completion signal. + ## Why It Matters This is the most workflow-builder-oriented reference app in the repo. It shows how Foblex Flow fits business-process editors where the canvas is only one part of the product and each node can expose richer form-driven configuration instead of being a static diagram box. diff --git a/apps/f-flow-portal/public/markdown/examples/reflow/reflow-on-resize.md b/apps/f-flow-portal/public/markdown/examples/reflow/reflow-on-resize.md index 9050be55..a087c411 100644 --- a/apps/f-flow-portal/public/markdown/examples/reflow/reflow-on-resize.md +++ b/apps/f-flow-portal/public/markdown/examples/reflow/reflow-on-resize.md @@ -2,7 +2,7 @@ toc: false wideContent: true publishedAt: "2026-04-25" -updatedAt: "2026-04-26" +updatedAt: "2026-07-13" --- # Reflow on Resize @@ -40,6 +40,29 @@ The simplest case — and the one the plugin was designed for. Three nodes, no r The remaining sections wire a resize handle onto the source so you can poke each option deliberately. The plugin reacts the same way regardless of what changes the size. +## Managed state: one undo step for expand and reflow + +With `withFlowState()`, a content toggle and the positions emitted by reflow should usually be one undoable action. They do not happen synchronously: the application stores the expanded value first, then Angular renders the new size, and only then does `ResizeObserver` produce the reflow `fMoveNodes` event. + +Open a state transaction before changing the value and close it after the resize-driven rendering turn. Do not use `state.batch(() => ...)` for this case because its synchronous callback finishes before reflow runs. + +```ts +state.beginBatch(); + +try { + state.updateNode(nodeId, { isExpanded }); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} finally { + state.endBatch(); +} +``` + +The managed state controller records the later `fMoveNodes` update inside the open transaction, so one `undo()` restores both the content state and all positions moved by reflow. For animated or asynchronously loaded content, close the transaction from the real layout-completion signal instead of assuming two frames are sufficient. See [Managed Flow State](./examples/state) for the complete explanation and failure modes. + +When the toggle is inside an `fDragHandle`, mark the toggle with `fDragBlocker`. This prevents the same pointer action from also selecting the node and creating a separate selection history item before the expand/reflow transaction. + ## Mode — who becomes a candidate `mode` decides **which nodes are eligible to shift** when the source resizes. The scene has a connected chain leaving the source (`Source → Connected → Connected`) and two unconnected nodes nearby. Drag any handle on the source and switch the dropdown: diff --git a/apps/f-flow-portal/public/search-index.json b/apps/f-flow-portal/public/search-index.json index d482e6e2..c16af573 100644 --- a/apps/f-flow-portal/public/search-index.json +++ b/apps/f-flow-portal/public/search-index.json @@ -1 +1 @@ -{"model":"Xenova/all-MiniLM-L6-v2","dim":384,"documents":[{"chunkId":"/docs/intro#0","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.09144,0.03305,-0.08091,0.0409,0.01845,0.03261,-0.03472,0.04744,-0.00621,0.07016,-0.11644,-0.03021,-0.06955,-0.00885,0.06349,0.00346,-0.04581,0.01837,0.03172,-0.00006,-0.06396,-0.01131,0.01158,-0.0378,-0.00561,0.05778,0.0116,-0.03553,0.02672,-0.04986,0.04434,0.08484,0.03272,0.02581,-0.12638,0.0976,0.02103,-0.05211,-0.04479,-0.05618,0.0432,0.0449,-0.0098,-0.08218,-0.03663,-0.03718,-0.06865,0.00792,-0.04732,0.07649,-0.06215,-0.14714,-0.07455,0.08407,-0.00539,0.00847,0.01748,-0.07883,0.03189,0.01862,0.00476,-0.01899,0.01787,0.05277,0.04483,0.01607,-0.11251,0.05295,0.00472,-0.08293,-0.01063,-0.04971,0.0388,-0.01733,-0.01576,-0.06321,-0.06629,0.041,-0.03902,-0.0122,0.01636,0.03994,-0.00592,0.06713,-0.05263,0.10672,0.02474,-0.05279,-0.00629,-0.02669,0.00316,0.06745,0.01668,-0.01231,0.06851,0.01879,-0.0065,-0.05273,-0.09703,0.01825,0.01578,-0.04218,0.04598,-0.01291,-0.05935,-0.04735,0.00891,-0.01157,-0.01434,-0.002,-0.0183,0.03774,-0.04742,-0.10744,-0.01823,-0.04973,-0.02185,-0.04707,0.04472,0.08387,0.02612,-0.00879,-0.004,-0.02554,0.09606,-0.01315,0.00496,0,0.03299,0.04843,-0.05852,0.06301,0.12149,-0.01917,0.0303,-0.09978,-0.01248,-0.01928,0.0589,0.06534,-0.04348,0.01979,-0.02748,-0.11582,-0.04763,0.00572,0.03057,-0.00437,0.07258,-0.07144,-0.02676,0.03956,0.07263,0.08084,-0.01004,0.0165,-0.04637,0.01591,0.05544,0.00989,0.00648,0.00855,-0.03321,0.00581,-0.02705,-0.07884,-0.00281,-0.05577,-0.02002,-0.07755,-0.09427,-0.00923,0.01212,-0.0058,-0.01957,-0.02583,0.11269,0.01607,0.00818,0.03852,0.00709,0.04546,0.08225,-0.03383,-0.04883,0.06872,0.04727,0.04865,0.06899,0.074,-0.02245,-0.06501,-0.0079,0.03466,0.00613,-0.00969,0.10218,-0.06128,-0.07243,0.04222,0.04169,0.0224,0.02833,-0.00699,0.00065,0.01243,0.07422,-0.03526,-0.04091,-0.06482,0.01107,0.10091,0.12284,-0.07357,0.00509,-0.02411,-0.10534,-0.00612,0.14713,0.04828,0.01054,-0.04645,0.05457,0,-0.00836,0.0099,-0.07783,0.09089,0.047,-0.0923,-0.01643,0.03852,0.06629,0.0304,0.02599,0.06709,0.0002,-0.01936,-0.00746,-0.01422,-0.02561,-0.1139,0.07113,-0.04648,0.05021,0.02185,-0.01041,-0.0654,-0.08959,0.04207,0.00915,-0.01495,0.00798,-0.04491,-0.00251,-0.00723,-0.03857,-0.02073,-0.00722,-0.04592,0.01604,0.07411,-0.00356,-0.07561,0.05883,-0.01563,-0.02654,-0.03972,-0.09326,-0.0221,-0.06072,0.02737,-0.01433,0.00806,-0.0329,0.01263,-0.03337,-0.08374,-0.06568,-0.01552,0.08194,0.01291,0.00319,0.03348,-0.02315,-0.00747,0.04163,0.02674,0.03738,0.02073,-0.04612,0.00674,0.01994,0.07247,0.0282,0.03983,-0.03867,0.01253,0.09774,0.05487,0.0984,-0.04363,0.05675,0.02398,0.01202,0.12695,-0.01245,0.04562,0.05826,0.02198,-0.09851,-0.05237,0.0266,0.05894,-0.08627,0.02152,0.03852,-0.00153,0.00415,0,-0.16854,0.00647,-0.04003,-0.00118,0.06177,0.03077,-0.054,0.01704,0.03263,-0.02222,-0.00257,0.06064,-0.0087,-0.00444,0.08002,-0.00165,-0.02702,-0.00395,-0.00744,-0.03069,0.02825,0.04785,0.011,0.07238,0.00938,0.02287,-0.00263,-0.00869,-0.00572,0.00411,-0.01598,0.05344,0.07531,0.0726,-0.05147,-0.00415,-0.06637,-0.00921,-0.10329,-0.04349,0.06614,-0.00734,0.02105,-0.05374,-0.10248,0.07326,-0.04165,-0.05503,-0.0117,-0.03426,-0.0518,0.01077,-0.06597,0.0552,0.02088,0.0455,-0.04746,-0.01614,0.05006,0.01812,-0.08361,0.07978,0.04215,0.02186],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Introducing Foblex Flow","excerpt":"Introducing Foblex Flow"},{"chunkId":"/docs/intro#1","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.07982,-0.00012,-0.07694,0.04453,0.02872,0.05387,-0.04729,0.09763,0.01315,0.08338,-0.10361,-0.0255,-0.0631,0.01185,0.05314,0.00968,-0.02012,-0.01396,-0.01246,0.00744,-0.0331,-0.05254,0.00166,-0.08234,0.0082,0.06533,0.00861,-0.01351,0.03892,-0.0615,-0.00029,0.08054,0.00388,0.03181,-0.13319,0.08966,0.06174,0.0001,-0.06634,-0.05193,0.05401,0.0494,0.00276,-0.07919,-0.0357,-0.02854,-0.07579,-0.0256,-0.05095,0.09906,-0.06663,-0.13911,-0.09941,0.08213,0.03247,0.03023,0.0225,-0.07665,0.00376,0.00755,0.0104,-0.02743,0.00839,0.03878,0.03469,-0.00318,-0.06625,0.02304,0.01686,-0.07107,-0.03641,-0.04054,0.00893,-0.02163,-0.0084,-0.04888,-0.03367,0.0766,-0.03258,-0.05807,0.05652,0.09735,-0.00984,0.08581,-0.04093,0.10826,0.03733,-0.06012,0.00597,-0.01055,-0.02037,0.0659,-0.01103,-0.04007,0.07206,0.01543,0.01009,-0.02947,-0.07976,0.01642,0.02696,-0.08195,0.07686,-0.03642,-0.04324,-0.04042,-0.02374,-0.00753,-0.05421,-0.0094,-0.0419,0.03699,-0.06145,-0.09337,-0.00254,-0.04569,-0.01917,-0.06455,0.0684,0.11046,-0.00682,0.05174,0.01515,0.01791,0.10368,-0.03335,-0.03262,0,0.02528,0.07198,-0.04414,0.07162,0.09587,0.02131,0.00259,-0.07463,-0.013,-0.00067,0.02064,0.06009,-0.06763,0.04845,0.02101,-0.10483,-0.01242,-0.00196,-0.01747,-0.01892,0.02873,-0.07709,-0.03636,0.03264,0.07162,0.08213,-0.00353,0.03525,-0.08425,-0.00894,0.01811,0.00223,0.00694,-0.00308,-0.04503,0.00684,-0.03644,-0.09477,-0.0233,-0.04429,-0.03327,-0.04094,-0.09684,0.00257,0.00122,-0.00755,-0.03515,-0.01277,0.08779,0.02555,-0.00106,0.04336,0.05582,0.02145,0.05277,-0.04183,-0.01801,0.05947,0.01721,0.06504,0.04924,0.0618,-0.0664,-0.02696,0.02122,0.06152,-0.01028,0.0076,0.09064,-0.04502,-0.12606,0.05762,0.06223,0.01402,0.04038,-0.00435,-0.01778,-0.01487,0.05857,-0.03263,-0.07514,-0.05371,0.00397,0.046,0.07561,-0.10915,0.03125,0.01326,-0.06773,-0.05666,0.13587,0.04352,0.02212,0.00031,0.03686,0,-0.01377,0.03575,-0.02802,0.08516,0.04182,-0.04378,0.0151,-0.00992,0.07137,-0.00231,0.02821,0.06413,0.02944,-0.0174,-0.01818,0.02433,-0.05667,-0.12452,0.07635,-0.08913,0.05083,0.0116,-0.00451,-0.05905,-0.07353,0.02776,0.02117,-0.03163,0.00128,-0.0182,-0.00593,-0.02303,-0.03306,-0.0478,-0.01021,-0.05575,0.03069,0.06225,0.00804,-0.05313,0.02028,0.00236,-0.00116,-0.01594,-0.08605,0.03199,-0.0699,0.01918,-0.04518,-0.00077,-0.05781,0.01425,-0.04064,-0.06725,-0.04718,-0.00761,0.07662,0.00744,0.01338,-0.00437,-0.02857,-0.0107,0.00254,-0.00775,0.02663,0.02482,-0.04418,-0.03273,0.02634,0.09554,0.02714,0.03078,-0.0066,-0.01603,0.11005,0.03248,0.12443,-0.03516,0.08495,0.02181,-0.00038,0.12753,-0.01962,0.0188,0.04589,0.04461,-0.09449,-0.01006,0.05806,0.04611,-0.07157,0.03845,0.01346,0.0232,-0.01132,0,-0.14144,0.0174,-0.06178,-0.01383,0.06129,-0.00519,-0.0117,0.06022,0.03646,0.00041,0.01677,0.05871,-0.02992,-0.03793,0.10888,0.002,-0.01546,0.02273,-0.02612,-0.04587,0.02987,0.02187,-0.00344,0.08203,0.00601,-0.03112,-0.00903,0.00055,-0.0085,0.01517,-0.02257,0.04965,0.07915,0.08285,-0.07524,-0.00112,-0.06795,-0.00192,-0.0843,0.02712,0.07569,0.00561,0.01301,-0.04782,-0.04891,0.06845,-0.0113,-0.06079,-0.04014,-0.03244,-0.063,0.01669,-0.04784,0.02653,0.01297,0.0682,-0.03003,-0.01048,0.07936,0.00057,-0.11555,0.07937,0.01975,0.0051],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Description Foblex Flow is an Angular-native library for building interactive node-based editors and diagram UIs - custom nodes, smooth connections, and production-grade interaction helpers. It’s designed for real products where users edit graphs manually: dragging nodes, connecting ports, selecting multiple items, aligning layouts, and navigating large canvases. The core path is intentionally small: f-flow, f-canvas, nodes, connectors, and connections. Advanced helpers such as selection area, minimap, alignment guides, caching, and virtualization are optional add-ons you introduce later if the editor grows. What you get - Core primitives for flow rendering: f-flow, f-canvas, nodes, connectors, and connections. - Interaction helpers for editor UX: drag, zoom, selection area, minimap, alignment lines, and equal spacing guides. - APIs that fit modern Angular: standalone components, signal-friendly patterns, SSR-aware setups. U Is AP Is","excerpt":"Description Foblex Flow is an Angular-native library for building interactive node-based editors and diagram UIs - custom nodes, smooth connections, and production-grade interaction helpers. It’s designed for real products where users edit graphs manually: dragging nodes, connecting ports, selecting multiple items, aligning layouts, and navigating large canvases. The core path is intentionally small: f-flow, f-canvas, nodes, connectors, and connections. Advanced helpers such as selection area, minimap, alignment guides, caching, and virtualization are optional add-ons you introduce later if the editor grows. What you get - Core primitives for flow rendering: f-flow, f-canvas, nodes, connectors, and connections. - Interaction helpers for editor UX: drag, zoom, selection area, minimap, alignment lines, and equal spacing guides. - APIs that fit modern Angular: standalone components, signal-friendly patterns, SSR-aware setups."},{"chunkId":"/docs/intro#2","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.0397,0.00126,-0.03148,0.02739,0.0211,-0.02681,-0.10233,0.09811,0.05586,0.04752,-0.11211,-0.02671,-0.03748,0.02344,0.0952,0.01944,-0.00042,-0.06454,0.00694,-0.01936,-0.07538,-0.05751,-0.01601,-0.06989,-0.00593,0.02933,0.01447,-0.043,0.05256,-0.07659,-0.01134,0.07734,-0.01833,0.02059,-0.07717,0.13513,0.0302,-0.03287,-0.07521,-0.06816,0.01464,0.05029,0.01087,-0.07033,-0.01372,-0.07536,-0.03054,-0.03561,-0.07276,0.08683,-0.12229,-0.14372,-0.0517,0.06542,0.00582,0.02006,0.04881,-0.07002,0.01756,-0.03044,-0.04505,-0.00749,0.01613,0.03705,0.02349,0.02499,-0.05646,0.04659,-0.0012,-0.03571,-0.01239,-0.04534,-0.02087,0.00193,-0.01289,-0.02514,-0.02268,0.01136,0.01781,-0.08807,0.03096,0.03336,-0.04966,0.09474,-0.04466,0.13902,0.04281,-0.04941,-0.00013,-0.01168,0.04888,0.05168,0.03764,-0.01797,0.05365,-0.01777,0.05601,-0.00848,-0.07182,0.04215,0.01671,-0.07904,0.08409,-0.01454,-0.07289,-0.01044,0.02053,-0.045,-0.06613,-0.01838,-0.03036,0.0212,-0.03344,-0.10862,-0.041,-0.07304,-0.03332,-0.04736,0.04692,0.09221,0.02938,0.06222,0.01585,0.0343,0.10865,-0.01049,-0.04713,0,0.03028,0.02118,-0.05256,0.04324,0.11496,-0.01229,0.02301,-0.06224,-0.01618,-0.00096,0.03485,0.04844,-0.04987,0.02417,0.02783,-0.06588,-0.02999,0.01006,-0.05294,0.02697,0.02728,-0.10166,-0.05513,0.07075,0.01878,0.13376,0.01811,0.0361,-0.02492,0.01612,0.03236,-0.00328,0.00538,0.04794,-0.03311,0.06903,-0.03216,-0.09007,0.04042,-0.02027,-0.05407,-0.04678,-0.07329,-0.02328,0.03163,-0.03176,-0.05738,-0.02048,0.04752,0.01461,0.03632,0.05522,0.08486,0.02879,0.06368,-0.05891,0.00212,0.01975,0.05884,0.10441,0.01714,0.10363,-0.04624,-0.0059,-0.01993,0.06286,-0.00062,-0.01547,0.10097,-0.05746,-0.08485,0.05617,0.04794,0.03167,0.05374,-0.04043,-0.0382,-0.0434,0.0624,-0.03989,-0.06673,-0.03909,-0.0098,0.03985,0.10396,-0.1008,-0.02834,0.01311,-0.1184,-0.00107,0.09036,-0.00475,-0.00217,0.02251,0.08201,0,-0.04529,0.00336,-0.04432,0.07874,0.02493,-0.09581,0.02077,-0.02307,0.07495,0.00047,0.01546,0.03354,-0.02817,0.00451,0.00389,-0.00961,-0.04041,-0.13856,0.05676,-0.07136,0.07983,0.02533,-0.03525,-0.00087,-0.0869,0.05805,-0.01385,-0.05817,0.01924,-0.0397,-0.01386,-0.02564,-0.04612,-0.05256,-0.00202,-0.05699,0.01712,0.0596,0.01993,-0.04527,0.02972,0.00013,-0.00179,0.03019,-0.07697,0.06858,-0.06578,-0.00932,0.02697,-0.03453,-0.03557,0.02417,-0.00238,-0.07151,-0.05821,-0.0257,0.0994,-0.01423,-0.05986,0.02956,-0.01868,-0.00577,0.06989,0.02144,0.00904,0.02427,-0.06208,0.02057,0.01839,0.05008,0.00771,0.05055,-0.02688,-0.02153,0.09167,0.05349,0.05251,-0.02999,0.07247,0.06231,-0.02754,0.09234,-0.00847,0.06439,0.0252,0.07793,-0.10089,-0.00262,0.01992,0.03049,-0.09265,0.03162,0.03836,0.05334,-0.00786,0,-0.12125,0.01937,-0.01522,-0.02561,0.03018,0.02485,-0.04833,0.03671,0.03293,0.00585,-0.02823,0.04042,-0.06631,-0.01374,0.1035,0.01968,-0.03162,0.01069,-0.03999,-0.06731,0.02214,0.03072,-0.01287,0.06823,-0.0069,-0.02092,-0.01225,-0.0122,-0.01161,0.02148,-0.01215,0.06011,0.06593,0.07079,-0.03503,-0.01583,-0.02477,0.01016,-0.04452,-0.00675,0.04159,0.02041,0.01407,-0.04055,-0.05574,0.04972,-0.0405,-0.04924,-0.02032,-0.01111,-0.09611,-0.03196,-0.10224,0.05473,0.03052,0.05011,0.01906,-0.01287,0.07293,0.02088,-0.02394,0.01823,0.04333,0.04517],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Why / Use cases Most diagram editors fail not on rendering, but on interaction details: hit-testing, pointer edge cases, snapping, selection UX, and consistent connection behavior. Foblex Flow exists for teams that want these building blocks in Angular without implementing low-level drag logic, hit-testing, and SVG path rendering from scratch. Typical use cases: - Workflow and automation builders (internal tools, operations tooling). - Low-code/no-code logic editors (rules, conditions, branching). - Visual programming tools and AI pipeline designers. - Back-office tools where entities and relations are edited as a graph.","excerpt":"Why / Use cases Most diagram editors fail not on rendering, but on interaction details: hit-testing, pointer edge cases, snapping, selection UX, and consistent connection behavior. Foblex Flow exists for teams that want these building blocks in Angular without implementing low-level drag logic, hit-testing, and SVG path rendering from scratch. Typical use cases: - Workflow and automation builders (internal tools, operations tooling). - Low-code/no-code logic editors (rules, conditions, branching). - Visual programming tools and AI pipeline designers. - Back-office tools where entities and relations are edited as a graph."},{"chunkId":"/docs/intro#3","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.09662,0.00787,-0.09005,0.02583,0.02035,0.01875,-0.02914,0.07247,-0.00571,0.07545,-0.11035,-0.02815,-0.06877,-0.02809,0.07782,0.00935,-0.04887,0.0066,0.03879,-0.02063,-0.04258,-0.01811,0.01126,-0.03058,-0.0146,0.06463,0.02607,-0.01886,0.03772,-0.04916,0.05314,0.08435,0.04645,0.02419,-0.1233,0.08807,0.03689,-0.03644,-0.05999,-0.0482,0.01046,0.04052,0.00367,-0.08438,-0.0339,-0.03268,-0.07433,-0.01596,-0.05531,0.06123,-0.06774,-0.14453,-0.06044,0.06829,0.01875,0.00936,0.01451,-0.06677,0.02041,0.00249,0.01004,-0.00293,0.00741,0.05685,0.03069,0.04092,-0.10652,0.07815,0.03488,-0.11849,-0.0228,-0.05942,0.02176,-0.01799,-0.01051,-0.06587,-0.05027,0.04362,-0.04251,-0.02208,0.00612,0.04574,0.00086,0.07748,-0.06256,0.09635,0.02957,-0.03035,0.01743,-0.00198,0.00276,0.08174,-0.00093,-0.0314,0.07873,0.01597,-0.00728,-0.0634,-0.08011,0.01101,-0.00243,-0.03948,0.05052,-0.02082,-0.06148,-0.03706,0.00056,-0.0046,0.00246,-0.00357,-0.02623,0.03483,-0.0314,-0.11584,-0.00142,-0.04944,-0.01722,-0.05703,0.05679,0.07651,0.02697,0.01769,-0.01303,-0.03081,0.08709,0.00619,-0.01021,0,0.0536,0.03428,-0.05146,0.08754,0.11316,-0.03187,0.05579,-0.07981,-0.02536,0.00015,0.05464,0.06414,-0.06024,0.05085,-0.04634,-0.12776,-0.05557,-0.00748,0.02507,0.00065,0.06843,-0.07087,-0.01435,0.05843,0.07692,0.0881,-0.01111,0.03339,-0.03054,0.01051,0.0228,0.00429,-0.0025,0.00231,-0.03696,0.02701,-0.04769,-0.06575,0.01456,-0.05187,-0.01401,-0.07596,-0.07457,-0.01114,0.0202,-0.00866,-0.05389,-0.03371,0.13178,0.01778,0.01334,0.03589,0.01319,0.05989,0.07589,-0.02355,-0.04955,0.05853,0.03039,0.06206,0.04856,0.08469,-0.04551,-0.06852,-0.00573,0.03896,-0.01215,-0.02441,0.10187,-0.05729,-0.08354,0.05158,0.03019,0.00725,0.03017,-0.00033,-0.00754,0.00392,0.04352,-0.02647,-0.02845,-0.06902,0.00956,0.09665,0.11517,-0.06327,0.00465,-0.03699,-0.09783,-0.03052,0.13931,0.02565,0.00112,-0.04164,0.0595,0,-0.01994,0.01648,-0.07793,0.08497,0.04025,-0.06529,-0.01367,0.03819,0.07947,0.00916,0.01728,0.0657,0.01403,-0.02277,-0.00689,-0.01612,-0.02563,-0.12441,0.08231,-0.03008,0.0382,0.03674,-0.0144,-0.07846,-0.08467,0.02119,0.00818,0.00109,0.02885,-0.04534,0.00812,-0.01196,-0.01698,-0.02307,-0.02397,-0.04343,0.02778,0.06375,0.00523,-0.08319,0.05383,-0.02069,-0.01261,-0.04542,-0.09613,0.01078,-0.07308,0.01719,-0.02174,-0.01093,-0.02723,0.01821,-0.02817,-0.08345,-0.06144,-0.0257,0.09409,0.01573,0.00138,0.03545,-0.04169,0.00591,0.05005,0.01819,0.01041,0.02181,-0.07726,-0.00378,0.00898,0.06592,0.01888,0.03667,-0.02884,0.01452,0.09476,0.05786,0.06805,-0.02658,0.05134,0.04098,0.03677,0.12216,0.00953,0.04726,0.05154,0.02768,-0.09505,-0.0528,0.03104,0.05205,-0.1014,0.02703,0.03688,0.02235,-0.00262,0,-0.15131,-0.00097,-0.0233,-0.0158,0.04189,0.0406,-0.03238,0.01844,0.01754,-0.01938,-0.01458,0.06797,-0.00468,-0.00594,0.06293,0.01646,-0.02617,0.00887,-0.00512,-0.03937,0.02436,0.03934,-0.00243,0.06913,0.02311,0.02098,0.00473,-0.00969,-0.01168,-0.00442,-0.02184,0.05754,0.09656,0.05286,-0.01054,0.00418,-0.07164,-0.01014,-0.08985,-0.04495,0.03334,0.0023,0.00134,-0.06562,-0.09427,0.07378,-0.03785,-0.05024,-0.03831,-0.02003,-0.05237,0.02312,-0.07055,0.05087,0.00833,0.07039,-0.05277,-0.01841,0.06226,-0.00736,-0.09006,0.11128,0.03971,0.02691],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Interactive examples Explore live demos built with Foblex Flow: - Call Flow Editor - Logic Configuration Tool - Schema Designer - Examples overview You can also browse the source code for all demos in libs/f-examples/.","excerpt":"Interactive examples Explore live demos built with Foblex Flow: - Call Flow Editor - Logic Configuration Tool - Schema Designer - Examples overview You can also browse the source code for all demos in libs/f-examples/."},{"chunkId":"/docs/intro#4","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.09371,-0.00788,-0.06311,0.03603,-0.00907,0.05701,-0.01535,0.05442,0.01655,0.03718,-0.0953,-0.01606,-0.0469,0.01262,0.09327,0.02008,0.00063,-0.03806,-0.03388,-0.00138,-0.0464,-0.08189,-0.0154,-0.02548,-0.02917,0.0795,0.02327,-0.07194,0.0734,-0.02988,0.08132,0.03977,-0.02435,0.00936,-0.13075,0.0988,0.03445,-0.04747,-0.06078,-0.03211,0.05769,0.0161,-0.01669,-0.05539,-0.04758,0.0037,-0.03231,-0.00442,-0.04246,0.08936,-0.00203,-0.06691,-0.03465,0.07771,0.0463,0.03583,0.0351,-0.05228,-0.01182,-0.00158,0.02904,-0.04703,0.02121,0.03741,-0.02355,0.06127,-0.05073,-0.00678,0.05914,-0.05276,-0.00575,-0.03225,0.00102,-0.07561,-0.00029,-0.07819,-0.01692,0.05193,-0.04747,-0.02256,0.05123,0.02688,0.0097,0.04709,-0.04933,0.04725,0.01304,-0.03146,0.02626,0.01049,-0.06363,0.09574,-0.00436,-0.07796,0.08139,0.01743,-0.02121,-0.00112,-0.04275,0.02579,0.03693,-0.05337,0.05718,0.05187,-0.02521,-0.06642,-0.08485,0.00123,-0.04861,0.02923,-0.01916,0.06619,0.02172,-0.02445,-0.025,0.00822,-0.03121,-0.02832,0.01777,0.10154,-0.00041,0.03423,0.05427,-0.02399,0.09378,-0.00969,0.004,0,-0.006,0.00777,-0.05292,0.01778,0.07426,-0.00078,0.03335,-0.0894,0.02256,-0.02509,0.03553,0.1072,-0.04284,0.03617,0.00837,-0.08499,-0.02313,-0.00629,0.09772,0.00773,0.0915,-0.03019,-0.03038,0.03444,0.10082,0.06867,-0.00836,0.06973,-0.03554,-0.01282,0.05612,-0.01767,-0.00726,-0.00518,-0.04538,-0.02076,-0.07235,-0.05169,-0.02317,-0.10386,0.02059,-0.04277,-0.14358,-0.01925,0.0026,-0.0471,-0.04991,-0.03957,0.06645,-0.02074,0.04428,0.01973,-0.0071,0.00284,0.02947,-0.05485,-0.04795,0.04414,-0.02213,0.00668,0.0539,0.02944,-0.05101,-0.00899,0.05294,0.05345,-0.0138,-0.00568,0.05315,-0.08424,-0.07528,0.09449,-0.0019,-0.0031,0.05669,-0.0096,-0.0181,0.01489,-0.01478,-0.01849,-0.02794,-0.07471,0.00621,0.13791,0.13018,-0.0935,0.02625,-0.03663,-0.08896,0.01092,0.10603,0.01462,0.02454,0.01181,0.02764,0,0.01278,-0.00198,-0.01287,0.11236,-0.00518,-0.05637,-0.00248,0.03827,0.04857,-0.01032,-0.01661,0.01964,0.03889,0.00677,-0.02886,0.01148,-0.03105,-0.08962,-0.01966,-0.03441,-0.00951,0.04088,-0.00959,-0.03262,-0.08576,0.02734,-0.02651,-0.03171,-0.02398,-0.0453,-0.04404,-0.03452,-0.04895,-0.0817,-0.01178,-0.05901,-0.00211,0.07537,0.01901,-0.04968,0.03197,0.00728,-0.08078,-0.01652,-0.06905,0.03029,-0.14963,0.02403,-0.08309,-0.01406,-0.08063,0.05105,-0.03566,-0.05487,-0.03997,0.0135,0.15162,0.01499,-0.02014,0.0098,-0.02157,-0.034,0.00859,0.0154,0.01963,0.02529,-0.04238,-0.00627,0.07458,0.10548,0.05741,0.06358,-0.0266,0.00703,0.15606,0.05944,0.07395,-0.0714,0.06654,-0.00772,-0.04858,0.10335,0.03432,0.0207,0.04314,0.05813,-0.08115,-0.07476,0.07109,0.04539,-0.0751,0.03445,-0.03078,0.00759,0.01619,0,-0.14433,0.01936,-0.05113,0.01159,0.05511,0.01723,-0.01162,0.0131,0.04865,0.00943,-0.02088,0.03703,-0.02217,-0.02366,0.03329,0.024,0.06349,-0.0276,-0.01228,-0.04096,0.0004,-0.02959,0.03494,0.12944,0.03447,-0.06892,0.07396,-0.01475,-0.00039,0.05395,-0.02399,0.01779,0.06962,0.10274,-0.08671,-0.02612,-0.01789,0.00594,-0.02004,0.03806,0.0297,0.04076,-0.06902,-0.05901,-0.06888,0.04628,-0.0302,-0.06578,0.00096,0.00588,-0.04566,0.01459,-0.05443,0.06904,0.0555,0.03845,-0.04445,0.00157,0.08142,0.00117,-0.08867,0.03719,0.00526,-0.0461],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. How it works You compose a flow from primitives: one f-flow root, one f-canvas, nodes with connectors, and connection components. Foblex Flow focuses on rendering and interaction. Your application owns the graph model (nodes, connections, metadata) and decides how to update it. The library emits interaction events (drag, selection changes, connection create/reassign) so you can: - keep business state in your own store (signals, RxJS, NgRx, plain services), - persist changes to your backend in a predictable way, - apply domain rules (validation, limits, permissions) where they belong. That is the default stateless integration. If you want the library to keep typed graph records and apply supported gestures automatically, install the optional Managed Flow State plugin with provideFFlow(withFlowState()). The core remains stateless, the plugin is opt-in, and the same public events remain available. Rx JS Ng Rx provide F Flow with Flow State","excerpt":"How it works You compose a flow from primitives: one f-flow root, one f-canvas, nodes with connectors, and connection components. Foblex Flow focuses on rendering and interaction. Your application owns the graph model (nodes, connections, metadata) and decides how to update it. The library emits interaction events (drag, selection changes, connection create/reassign) so you can: - keep business state in your own store (signals, RxJS, NgRx, plain services), - persist changes to your backend in a predictable way, - apply domain rules (validation, limits, permissions) where they belong. That is the default stateless integration. If you want the library to keep typed graph records and apply supported gestures automatically, install the optional Managed Flow State plugin with provideFFlow(withFlowState()). The core remains stateless, the plugin is opt-in, and the same public events remain available."},{"chunkId":"/docs/intro#5","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.06856,0.00128,-0.05067,0.04749,0.00979,0.00939,-0.08636,0.0714,0.05983,0.08056,-0.13365,-0.01366,-0.07838,0.0034,0.07947,-0.01053,0.01544,-0.00859,0.01973,-0.03869,-0.08799,-0.04907,0.01663,-0.05525,-0.00101,0.04141,-0.03119,-0.02134,0.0479,-0.04692,0.00311,0.05037,0.03161,0.03321,-0.10989,0.14473,0.06655,-0.02791,-0.08609,-0.05943,0.02218,0.0462,-0.01871,-0.06919,0.00406,-0.07131,-0.02993,-0.03105,-0.07194,0.05907,-0.05046,-0.16358,-0.05688,0.07995,-0.00203,0.02688,0.01187,-0.08214,0.03662,0.00572,-0.01114,0.01037,0.01017,0.04708,0.02905,0.00547,-0.04785,0.03518,0.0446,-0.10729,-0.02438,-0.07172,-0.01115,-0.00296,-0.01671,-0.08069,-0.02957,0.02634,-0.01977,-0.05087,0.03855,0.04608,0.00129,0.0886,-0.05349,0.10413,0.05283,-0.0176,0.01304,0.0339,0.02978,0.10424,0.03198,-0.05327,0.0746,0.03578,0.02139,0.00336,-0.06922,0.01488,0.00651,-0.09198,0.0706,-0.01386,-0.03712,-0.02765,0.02604,-0.03004,-0.03756,0.00622,-0.02352,0.02798,-0.03883,-0.10753,0.00643,-0.03845,-0.01196,-0.05061,0.03487,0.12264,-0.00451,0.02505,0.01046,0.00215,0.0845,-0.02648,-0.01485,0,0.04646,0.08849,-0.04435,0.08224,0.10059,-0.00097,0.03578,-0.06671,-0.02652,-0.03817,0.05116,0.05188,-0.05393,0.05278,0.02225,-0.07144,-0.05754,0.00684,-0.00891,-0.01814,0.04166,-0.08665,-0.03928,0.07717,0.05682,0.06401,0.02104,0.0229,-0.03206,0.00176,0.00467,-0.02333,0.02127,0.02656,-0.0477,0.05796,-0.04982,-0.08333,0.0375,-0.04575,-0.03408,-0.01316,-0.07362,-0.04174,0.00121,-0.01074,-0.0246,-0.0199,0.09932,0.00568,0.03586,0.0405,0.05815,0.07163,0.06695,0.00132,0.03327,0.0016,0.03413,0.05307,0.03415,0.09261,-0.04524,-0.00243,0.02875,0.07253,0.00673,0.00634,0.08658,-0.08933,-0.09261,0.02537,0.05323,-0.0077,0.04374,0.00006,-0.00939,-0.00635,0.04232,0.01998,-0.05623,-0.05234,0.00563,0.11744,0.09395,-0.07407,-0.00079,0.02443,-0.06693,-0.00564,0.13295,0.00863,0.00051,-0.00825,0.01563,0,-0.02305,-0.01495,-0.04573,0.09285,0.02113,-0.06894,-0.04722,-0.01437,0.09242,0.00427,0.01325,0.02334,-0.00013,-0.03094,-0.01202,-0.02734,-0.01987,-0.13667,0.06565,-0.07386,0.05977,0.00754,0.00702,-0.02585,-0.0638,0.00634,-0.02539,-0.02782,-0.0441,-0.05841,-0.01863,-0.0351,-0.03236,-0.09627,-0.02063,-0.03034,-0.01972,0.07358,0.00826,-0.0357,0.0383,0.00818,-0.02912,-0.01749,-0.1,0.03849,-0.08248,0.00691,-0.02789,-0.01649,-0.04015,0.0472,-0.02781,-0.08656,-0.0362,-0.02414,0.04884,0.04288,-0.02425,0.01016,-0.03496,-0.0012,0.0407,0.0138,-0.0164,0.03781,-0.05016,0.0219,-0.00399,0.06802,0.04802,0.00879,-0.03713,-0.00077,0.0598,0.04666,0.10778,-0.05792,0.05666,0.02368,-0.04463,0.12251,-0.00074,0.00945,0.03476,0.01882,-0.08362,-0.07457,0.03452,0.06965,-0.06012,0.01223,0.03277,0.02087,-0.02115,0,-0.13416,0.02836,-0.0249,-0.01501,0.02535,0.03046,-0.03542,0.05896,0.06214,0.04423,0.06131,0.00609,-0.02936,-0.02264,0.0679,0.02929,0.00729,0.02638,-0.03636,-0.09117,0.02946,0.07209,-0.02968,0.0522,0.04773,-0.0073,0.04181,-0.02176,-0.05039,0.01933,-0.04184,0.081,0.0591,0.09542,-0.05857,-0.02585,-0.04549,0.01857,-0.09196,-0.04142,0.05209,0.02353,0.02309,-0.04169,-0.10224,0.04138,-0.03743,-0.04307,-0.00106,-0.04858,-0.03998,-0.00203,-0.07082,0.03046,-0.01036,0.07206,-0.00993,-0.02053,0.06579,-0.02726,-0.08685,0.04062,0.04258,0.01674],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Quick FAQ - Is Foblex Flow hard to use? No. The minimal editor path is small and stays inside normal Angular templates. - Do I need caching or virtualization? No. Most editors start without them. They are optional tools for larger scenes and heavier redraw workloads. - Who is it best for? Angular teams building node editors, workflow builders, interactive diagrams, and graph-based internal tools.","excerpt":"Quick FAQ - Is Foblex Flow hard to use? No. The minimal editor path is small and stays inside normal Angular templates. - Do I need caching or virtualization? No. Most editors start without them. They are optional tools for larger scenes and heavier redraw workloads. - Who is it best for? Angular teams building node editors, workflow builders, interactive diagrams, and graph-based internal tools."},{"chunkId":"/docs/intro#6","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.09529,0.03582,-0.08632,0.04206,-0.01054,0.06754,-0.00562,0.07485,-0.00586,0.07218,-0.11104,-0.02422,-0.06654,-0.01606,0.05641,0.00948,-0.04232,0.01276,0.02217,-0.00359,-0.05116,-0.03609,-0.00413,-0.05767,-0.00696,0.04772,0.03274,-0.02067,0.03654,-0.03296,0.0419,0.07613,0.02034,0.01967,-0.13493,0.08909,0.02715,-0.0511,-0.06573,-0.04681,0.06442,0.05273,0.01355,-0.06343,-0.0372,-0.02265,-0.07751,0.0336,-0.05657,0.07259,-0.05049,-0.12819,-0.06668,0.08654,-0.01279,0.00652,0.00971,-0.07216,0.03774,0.02551,0.02707,-0.01556,0.00612,0.04244,0.02702,0.0149,-0.09507,0.06659,-0.00578,-0.07164,-0.01604,-0.06461,0.0197,-0.02022,0.00703,-0.06645,-0.05235,0.07565,-0.0382,-0.02661,0.01861,0.04804,0.00258,0.071,-0.07572,0.10119,0.01838,-0.05215,-0.01403,-0.02723,0.02073,0.08905,-0.02123,-0.03318,0.07026,0.02695,-0.02089,-0.04437,-0.07608,0.00343,-0.00187,-0.0617,0.0278,-0.01573,-0.0579,-0.04536,-0.00545,-0.0412,-0.00911,0.01952,-0.04372,0.03911,-0.0546,-0.08739,-0.01942,-0.03407,-0.03995,-0.04313,0.05439,0.07646,-0.00838,0.01777,-0.01569,-0.03182,0.07492,-0.01053,0.00907,0,0.03274,0.03569,-0.06969,0.0802,0.1037,-0.02112,0.04482,-0.0899,-0.02593,0.01501,0.04773,0.05273,-0.05421,0.04802,-0.0489,-0.12529,-0.03792,-0.03228,0.04445,0.01045,0.06749,-0.06934,-0.02545,0.03769,0.08931,0.06808,-0.01658,0.03234,-0.02453,0.01049,0.04012,0.0224,-0.00342,-0.00419,-0.03409,0.03514,-0.03489,-0.04078,-0.00876,-0.04419,-0.00996,-0.08835,-0.10619,-0.00372,0.00987,-0.02435,-0.02105,-0.03924,0.13096,0.00195,-0.00396,0.00953,0.01955,0.04801,0.07789,-0.03853,-0.07447,0.05704,0.00258,0.05329,0.07379,0.06426,-0.03861,-0.08048,0.01063,0.05042,-0.03601,0.00003,0.07687,-0.07243,-0.07954,0.04926,0.0577,0.00662,0.03095,0.01169,0.00376,0.02805,0.06863,-0.02532,-0.05578,-0.06118,0.04334,0.10242,0.1137,-0.07142,0.02199,-0.01307,-0.0826,-0.0094,0.14373,0.04223,0.00279,-0.03392,0.08586,0,-0.01704,0.03969,-0.04583,0.09994,0.05218,-0.06643,-0.00522,0.0334,0.07725,0.02311,0.01888,0.05497,0.01024,-0.02169,-0.01556,-0.02355,-0.05231,-0.13738,0.06482,-0.05012,0.04038,0.01153,0.00699,-0.09082,-0.08267,0.00478,0.00356,-0.01048,-0.00725,-0.02354,-0.00435,-0.01677,-0.03529,-0.0028,-0.01289,-0.0424,0.0228,0.09041,0.02611,-0.09218,0.04574,-0.03879,-0.03079,-0.03249,-0.06845,0.00009,-0.07209,0.00215,-0.02238,0.00565,-0.02929,0.04168,-0.04657,-0.06471,-0.05307,-0.01558,0.09416,0.0075,0.01358,0.02249,-0.02669,0.00065,0.04135,0.01815,0.03286,0.01777,-0.05005,-0.01602,0.03365,0.07527,-0.00919,0.03703,-0.00805,0.01778,0.11885,0.03027,0.09703,-0.03902,0.05651,0.02919,0.04578,0.13484,-0.02909,0.04134,0.05555,0.04794,-0.08528,-0.06822,0.03267,0.06819,-0.09139,0.01367,0.01777,-0.01076,-0.01653,0,-0.15217,-0.00174,-0.0435,-0.03068,0.04242,0.0442,-0.0358,0.02334,0.04384,-0.01021,-0.01652,0.06627,0.00483,-0.0076,0.06518,0.0001,-0.00277,-0.00322,0.00238,-0.03718,0.01184,0.02844,0.02507,0.07716,0.02756,0.00043,0.00733,-0.00291,-0.0188,0.0049,-0.02367,0.06004,0.05841,0.06467,-0.04309,0.00195,-0.05158,0.00532,-0.08983,-0.01882,0.05459,0.0296,0.01241,-0.0585,-0.08425,0.07007,-0.02664,-0.0404,-0.01372,-0.00442,-0.04074,0.03038,-0.04516,0.04434,0.01215,0.04117,-0.05682,-0.02212,0.04882,-0.00962,-0.14484,0.07374,0.03959,0.01606],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Next steps This page is an overview. Use these guides for exact contracts: - Flow - Canvas - Node - Connection - Drag and Drop","excerpt":"Next steps This page is an overview. Use these guides for exact contracts: - Flow - Canvas - Node - Connection - Drag and Drop"},{"chunkId":"/docs/intro#7","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.08673,0.05078,-0.08849,0.04598,0.00529,0.05198,-0.05246,0.06677,0.01204,0.05771,-0.12083,-0.02295,-0.07634,-0.02061,0.01995,0.00602,-0.03787,-0.00281,0.01273,0.01117,-0.06288,-0.0367,0.02346,-0.05475,-0.02398,0.06125,0.04301,-0.00887,0.02537,-0.05604,0.0347,0.09334,0.00911,0.00829,-0.13475,0.08608,0.05196,-0.0237,-0.05548,-0.04165,0.05756,0.03674,0.00986,-0.05168,-0.03436,-0.0158,-0.07892,-0.00359,-0.06143,0.07176,-0.04395,-0.11222,-0.05728,0.09687,-0.00264,0.02668,0.00257,-0.08736,0.03116,0.04989,0.03935,-0.02126,0.03189,0.04167,0.06686,0.02359,-0.08299,0.0202,-0.00793,-0.08199,-0.00351,-0.02877,0.01361,-0.03074,-0.00955,-0.03414,-0.04502,0.04577,-0.03333,-0.04018,0.03774,0.08882,0.00083,0.07533,-0.03398,0.12412,0.01214,-0.04153,-0.01108,-0.00931,-0.00309,0.06115,-0.00097,-0.01778,0.07566,0.04261,-0.00125,-0.05581,-0.09527,-0.00077,0.00027,-0.0346,0.06332,0.00099,-0.03656,-0.04484,-0.00592,0.01064,-0.02475,-0.03007,-0.00769,0.04305,-0.04976,-0.06699,-0.0295,-0.04591,-0.03952,-0.06249,0.06053,0.09723,0.01637,0.01812,-0.01487,-0.02368,0.08856,0.01715,-0.00982,0,0.02785,0.04571,-0.05515,0.06459,0.10697,-0.01933,0.02078,-0.11353,-0.01475,0.01045,0.00338,0.01458,-0.07118,0.00336,-0.02057,-0.11588,-0.04175,0.02815,-0.00459,-0.0039,0.05478,-0.0739,-0.02606,-0.00197,0.09072,0.09456,-0.02454,0.03771,-0.06689,0.00083,0.04479,-0.00233,0.02321,-0.00871,-0.04492,0.00167,-0.0326,-0.08211,-0.02875,-0.04427,-0.03675,-0.0276,-0.07584,0.02552,0.00991,-0.00552,-0.02609,-0.02576,0.11632,0.01645,-0.01958,0.04326,0.02148,0.01709,0.07182,-0.07113,-0.06103,0.06619,0.01333,0.04518,0.05154,0.07569,-0.07459,-0.05801,0.00232,0.06103,-0.00189,-0.0251,0.08975,-0.06244,-0.0686,0.0433,0.04304,0.03302,0.04218,0.00904,0.00401,0.00893,0.06053,-0.02241,-0.03553,-0.03824,-0.0168,0.10349,0.13218,-0.05878,0.01864,-0.02713,-0.08308,-0.00131,0.14387,0.05707,0.02033,-0.04988,0.04573,0,-0.0244,0.00922,-0.06999,0.07141,0.02329,-0.07611,-0.00057,0.01994,0.07765,0.00806,0.04518,0.08491,-0.01053,-0.05073,0.00673,-0.00801,-0.04407,-0.11879,0.10968,-0.05681,0.07632,0.0259,-0.02134,-0.0639,-0.06868,0.02898,-0.00114,-0.0463,-0.00263,-0.04467,0.01178,-0.01549,-0.03754,-0.01305,-0.01445,-0.05201,-0.0115,0.07704,0.02346,-0.0388,0.02653,0.00229,-0.02391,0.00014,-0.0877,-0.0245,-0.08195,-0.00357,-0.02636,0.00114,-0.05373,0.03277,-0.03515,-0.07383,-0.05723,-0.01905,0.08606,0.02157,-0.00283,0.01797,-0.00834,-0.01965,0.03531,0.01514,0.03295,0.03132,-0.0606,-0.00023,0.03374,0.08541,0.04091,0.0142,-0.01161,0.01564,0.10917,0.01365,0.09847,-0.03592,0.08488,0.05058,0.00153,0.15577,-0.01987,0.01901,0.07302,0.01667,-0.0433,-0.02589,0.03479,0.06233,-0.08334,0.02695,0.01287,-0.00542,-0.0108,0,-0.1711,0.01063,-0.05658,-0.02877,0.05748,0.00295,-0.07935,0.03032,0.03689,-0.03039,-0.01245,0.0859,-0.00183,-0.03148,0.09711,-0.00551,-0.03577,0.00859,-0.04092,-0.02525,0.00316,0.05469,-0.01773,0.05861,-0.00284,-0.00655,0.03297,-0.00682,-0.01944,0.01719,-0.02541,0.05261,0.06986,0.08532,-0.03432,0.02352,-0.07818,0.00822,-0.09558,-0.01323,0.05386,-0.0412,0.01278,-0.05092,-0.08211,0.06663,-0.04598,-0.0188,-0.02985,-0.01192,-0.04857,-0.00898,-0.05872,0.03543,0.02674,0.0167,-0.02547,-0.00902,0.05518,0.00344,-0.08276,0.10696,0.05526,0.00856],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Notes / Pitfalls - Use stable ids for nodes and connectors to keep connections predictable (fNodeId, fInputId, fOutputId). - Style primitives explicitly for production UI - defaults are intentionally minimal. - Add advanced helpers (selection area, minimap, alignment/spacing) after base rendering is stable. f Node Id f Input Id f Output Id","excerpt":"Notes / Pitfalls - Use stable ids for nodes and connectors to keep connections predictable (fNodeId, fInputId, fOutputId). - Style primitives explicitly for production UI - defaults are intentionally minimal. - Add advanced helpers (selection area, minimap, alignment/spacing) after base rendering is stable."},{"chunkId":"/docs/intro#8","slug":"intro","route":"/docs/intro","section":"docs","title":"Foblex Flow Docs — Getting Started with Angular Node Editors","description":"Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns.","group":"Introduction","vector":[-0.08612,0.06575,-0.08594,0.04464,0.03342,0.09478,-0.03808,0.06217,-0.0305,0.06381,-0.07894,-0.02001,-0.04526,-0.02724,0.02623,0.00604,-0.08611,0.03612,-0.00634,-0.02094,-0.00931,-0.04138,0.02793,-0.02648,-0.02796,0.04153,-0.01095,0.00807,0.04991,-0.01961,0.07852,0.10176,0.01319,0.03764,-0.06084,0.08975,0.01764,0.00886,-0.06546,-0.03047,0.10499,0.0477,0.00611,-0.11199,-0.01973,-0.02984,-0.07827,0.01323,-0.06979,0.03259,-0.05547,-0.1425,-0.03861,0.08192,0.02776,0.01967,-0.01039,-0.11903,0.05876,-0.01209,0.02065,-0.05585,0.00511,0.00696,0.06984,0.02826,-0.12003,0.04289,-0.02164,0.00752,0.00108,-0.04263,0.00247,0.00585,0.0075,-0.04138,-0.06021,0.08238,-0.05569,-0.05112,-0.0189,0.01845,-0.00232,0.04205,-0.04674,0.14399,0.00755,-0.05175,-0.00096,-0.00427,-0.01887,0.06278,-0.06124,-0.03358,0.0647,0.04648,-0.04374,-0.01396,-0.06545,0.03522,0.02297,-0.07187,0.02397,0.02455,-0.00902,-0.0233,-0.02967,0.02471,-0.04369,0.00064,-0.04677,0.03897,-0.01744,-0.09679,-0.00761,-0.05987,-0.01084,-0.0497,0.05319,0.08405,0.05768,-0.01015,-0.06015,-0.01931,0.08814,0.02329,0.00228,0,0.02457,0.03182,-0.04024,0.06336,0.09672,-0.0345,0.01678,-0.0848,-0.03281,0.03766,-0.01029,0.03709,-0.05228,-0.02136,-0.06292,-0.14083,-0.03118,0.00217,-0.00221,-0.02323,0.04887,-0.05915,-0.05308,0.01636,0.0696,0.01271,-0.03129,0.01692,-0.05813,-0.00017,0.08261,0.04631,0.0712,0.00581,-0.0093,0.00344,0.01315,-0.03885,-0.04698,-0.04585,0.03296,-0.04927,-0.04484,0.043,-0.01598,-0.03397,-0.01643,-0.04346,0.11934,-0.01303,0.01677,0.06895,-0.00166,-0.01572,0.09797,-0.0061,-0.04507,0.04713,0.00849,0.04802,0.03797,0.08384,-0.00314,-0.06421,0.03773,0.03662,-0.00851,-0.01425,0.0631,-0.09493,-0.06537,0.09417,0.03028,0.01058,0.02943,0.00945,0.00451,0.02378,0.06189,-0.04048,-0.05651,-0.06985,-0.02951,0.09864,0.11517,-0.10629,0.01791,-0.03951,-0.07072,-0.07936,0.13084,0.0104,-0.04738,-0.07935,0.02793,0,0.01513,0.04246,-0.04179,0.11248,0.01822,-0.04927,0.02383,0.06705,0.06257,0.05786,-0.00085,0.05528,0.00029,-0.06077,0.01313,-0.02655,-0.04769,-0.10391,0.10018,-0.05758,0.05654,0.09909,-0.03093,-0.02467,-0.05984,0.012,0.01051,0.03665,-0.00864,-0.05709,-0.00867,-0.0449,-0.08552,0.01317,0.00367,-0.07034,-0.01803,0.11468,0.02696,-0.04477,0.06725,0.0261,-0.05477,-0.01744,-0.05586,-0.01792,0.00369,0.00945,-0.01982,-0.02387,-0.03918,-0.01118,-0.04477,0.00087,-0.03134,-0.01608,0.09582,-0.02099,-0.02258,0.00761,0.0233,-0.03823,0.00409,-0.00338,0.0629,0.01781,-0.08582,-0.03946,0.01944,0.04745,-0.029,0.02024,0.02346,-0.00752,0.07448,0.02496,0.06742,0.01919,0.06593,0.055,-0.06864,0.13474,-0.01175,0.07448,0.0841,0.0272,-0.10853,0.00401,0.05429,0.0299,-0.07205,0.00265,0.00968,0.01899,0.02608,0,-0.15793,-0.00166,-0.04094,-0.00956,0.0483,-0.01898,-0.00416,-0.01306,0.00204,-0.04233,-0.01755,0.05193,0.0394,-0.04158,0.06448,-0.03447,-0.02964,0.01783,-0.04323,0.00437,-0.01545,0.03363,0.01763,0.09974,0.00649,-0.01393,-0.02499,-0.00278,0.00288,0.00022,-0.00714,0.01746,0.06061,0.07266,-0.06127,0.00497,-0.10184,0.00979,-0.04469,0.00601,0.04244,-0.06572,0.00756,-0.0215,-0.05755,0.09223,-0.04347,-0.00402,0.0013,-0.00611,-0.05303,0.02923,-0.07191,0.05936,0.05122,-0.01259,0.00313,0.01481,0.02292,-0.00689,-0.05172,0.08854,-0.00059,0.01535],"keywords":"Foblex Flow Docs — Getting Started with Angular Node Editors Foblex Flow Docs — Getting Started with Angular Node Editors Learn how to build interactive node-based UIs in Angular with Foblex Flow. Custom nodes, drag-to-connect, minimap, undo/redo, and production-grade patterns. Example ::: ng-component