Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions apps/example-apps/call-center/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}

public async setNodeExpanded(nodeId: string, isExpanded: boolean): Promise<void> {
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: <https://flow.foblex.com/examples/state>

## Run Locally

From this directory:
Expand Down
2 changes: 1 addition & 1 deletion apps/example-apps/call-center/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/example-apps/call-center/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<flow [id]="'123'" />
<call-center-flow flowId="123" />
4 changes: 2 additions & 2 deletions apps/example-apps/call-center/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<div class="toolbar">
<button
mat-icon-button
(click)="deleteSelection()"
[disabled]="!canDeleteSelection()"
aria-label="Delete selection"
matTooltip="Delete"
matTooltipPosition="above"
>
<mat-icon>delete</mat-icon>
</button>

<button
mat-icon-button
(click)="state.undo()"
[disabled]="!state.canUndo()"
aria-label="Undo"
matTooltip="Undo"
matTooltipPosition="above"
>
<mat-icon>undo</mat-icon>
</button>

<button
mat-icon-button
(click)="state.redo()"
[disabled]="!state.canRedo()"
aria-label="Redo"
matTooltip="Redo"
matTooltipPosition="above"
>
<mat-icon>redo</mat-icon>
</button>

<button
mat-icon-button
(click)="actionTriggered.emit(action.SELECT_ALL)"
aria-label="Select all"
matTooltip="Select All"
matTooltipPosition="above"
>
<mat-icon>select_all</mat-icon>
</button>

<div class="divider"></div>

<button
mat-icon-button
(click)="actionTriggered.emit(action.ZOOM_IN)"
aria-label="Zoom in"
matTooltip="Zoom In"
matTooltipPosition="above"
>
<mat-icon>zoom_in</mat-icon>
</button>

<button
mat-icon-button
(click)="actionTriggered.emit(action.ZOOM_OUT)"
aria-label="Zoom out"
matTooltip="Zoom Out"
matTooltipPosition="above"
>
<mat-icon>zoom_out</mat-icon>
</button>

<button
mat-icon-button
(click)="actionTriggered.emit(action.FIT_TO_SCREEN)"
aria-label="Fit to screen"
matTooltip="Fit to Screen"
matTooltipPosition="above"
>
<mat-icon>fit_screen</mat-icon>
</button>

<button
mat-icon-button
(click)="actionTriggered.emit(action.ONE_TO_ONE)"
aria-label="Reset zoom"
matTooltip="1:1"
matTooltipPosition="above"
>
<mat-icon>crop_free</mat-icon>
</button>

<div class="divider"></div>

<button
mat-icon-button
[matMenuTriggerFor]="themeMenu"
aria-label="Theme"
matTooltip="Theme"
matTooltipPosition="above"
>
<mat-icon>{{ currentTheme() === 'dark' ? 'dark_mode' : 'light_mode' }}</mat-icon>
</button>

<mat-menu #themeMenu="matMenu">
<button mat-menu-item (click)="setTheme('light')" [disabled]="currentTheme() === 'light'">
Light
</button>
<button mat-menu-item (click)="setTheme('dark')" [disabled]="currentTheme() === 'dark'">
Dark
</button>
</mat-menu>

<button
mat-icon-button
(click)="requestReset()"
aria-label="Reset flow"
matTooltip="Reset"
matTooltipPosition="above"
>
<mat-icon>restart_alt</mat-icon>
</button>
</div>
Original file line number Diff line number Diff line change
@@ -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<ECallCenterFlowAction>();
public readonly resetRequested = output<void>();

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();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export enum FlowActionPanelAction {
export enum ECallCenterFlowAction {
ZOOM_IN = 'ZOOM_IN',

ZOOM_OUT = 'ZOOM_OUT',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<call-center-flow-toolbar
(actionTriggered)="executeAction($event)"
(resetRequested)="resetFlow()"
/>
<call-center-node-palette />

<f-flow fDraggable aria-label="Call center flow editor" (fFullRendered)="editorLoaded()">
<f-background>
<f-circle-pattern />
</f-background>
<f-line-alignment [fAlignThreshold]="20" />
<f-selection-area />
<f-canvas
fZoom
[scale]="transform().scale"
[position]="transform().position"
[debounceTime]="200"
>
@for (connection of connections(); track connection.id) {
<f-connection
fBehavior="fixed"
[fConnectionId]="connection.id"
[fOffset]="32"
fType="segment"
[fSourceId]="connection.sourceId"
[fTargetId]="connection.targetId"
fTargetSide="calculate"
>
<f-connection-marker-arrow />
</f-connection>
}

<f-connection-for-create fType="segment" [fOffset]="0">
<f-connection-marker-arrow />
</f-connection-for-create>

@for (node of nodes(); track node.id) {
<call-center-node
fNode
fConnector
fConnectorType="target"
[attr.aria-label]="nodeAriaLabel(node)"
[fNodeId]="node.id"
[fConnectorId]="node.input"
[fConnectorDisabled]="!node.input"
[fNodePosition]="node.position"
[node]="node"
/>
}
</f-canvas>
<f-minimap [fMinSize]="2000" />
</f-flow>
Loading
Loading