guided tour added for editor part

This commit is contained in:
Lucio Lelii 2026-03-17 09:54:40 +01:00
parent 2a42817ef3
commit 08d83863a1
8 changed files with 425 additions and 6 deletions

View File

@ -125,6 +125,75 @@
line-height: 1.5;
}
.flow-editor-tour-target {
height: 100%;
}
.flow-editor-tour-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.46);
z-index: 1500;
}
.flow-editor-tour-spotlight {
position: fixed;
border-radius: 18px;
box-shadow:
0 0 0 9999px rgba(15, 23, 42, 0.18),
0 0 0 3px rgba(255, 255, 255, 0.9),
0 18px 36px rgba(15, 23, 42, 0.18);
z-index: 1501;
pointer-events: none;
}
.flow-editor-tour-card {
position: fixed;
width: min(320px, calc(100vw - 32px));
padding: 18px;
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid rgba(148, 163, 184, 0.35);
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.24);
z-index: 1502;
}
.flow-editor-tour-step {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #0f766e;
}
.flow-editor-tour-title {
margin: 8px 0 6px;
font-size: 20px;
font-weight: 800;
color: #0f172a;
}
.flow-editor-tour-text {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #334155;
}
.flow-editor-tour-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
}
.flow-editor-tour-actions-right {
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 1100px) {
.flow-editor-main {
flex-direction: column;
@ -143,4 +212,10 @@
min-width: 72px;
height: 42vh;
}
.flow-editor-tour-card {
left: 16px !important;
right: 16px;
width: auto;
}
}

View File

@ -7,15 +7,21 @@
<div class="flex flex-row flex-1 overflow-hidden">
<!-- LEFT SIDEBAR -->
<app-editor-sidebar></app-editor-sidebar>
<div data-tour="editor-sidebar">
<app-editor-sidebar></app-editor-sidebar>
</div>
<!-- CENTER EDITOR -->
<main class="flow-editor-main">
@if (flow()) {
<mat-card class="flow-editor-canvas-shell">
<app-title-toolbar></app-title-toolbar>
<div data-tour="editor-title-toolbar">
<app-title-toolbar></app-title-toolbar>
</div>
<div class="flow-editor-canvas">
<app-rete-editor [flowId]="flow()!.id" [flowData]="flow()!.data" [readonly]="readonly()"></app-rete-editor>
<div data-tour="editor-canvas" class="flow-editor-tour-target">
<app-rete-editor [flowId]="flow()!.id" [flowData]="flow()!.data" [readonly]="readonly()"></app-rete-editor>
</div>
</div>
</mat-card>
} @else {
@ -27,7 +33,7 @@
</main>
@if (assistantEnabled && !readonly()) {
<section class="flow-editor-assistant" [class.flow-editor-assistant-collapsed]="!assistantOpen()">
<section class="flow-editor-assistant" data-tour="editor-assistant" [class.flow-editor-assistant-collapsed]="!assistantOpen()">
<button
type="button"
class="flow-editor-assistant-toggle"
@ -51,4 +57,23 @@
}
</div>
@if (tourActive() && activeTourStep(); as step) {
<div class="flow-editor-tour-overlay" (click)="skipTour()"></div>
<div class="flow-editor-tour-spotlight" [ngStyle]="tourSpotlightStyle()"></div>
<section class="flow-editor-tour-card" [ngStyle]="tourCardStyle()" (click)="$event.stopPropagation()">
<div class="flow-editor-tour-step">Step {{ tourStepIndex() + 1 }} / {{ tourSteps().length }}</div>
<h3 class="flow-editor-tour-title">{{ step.title }}</h3>
<p class="flow-editor-tour-text">{{ step.description }}</p>
<div class="flow-editor-tour-actions">
<button type="button" mat-stroked-button (click)="skipTour()">Skip</button>
<div class="flow-editor-tour-actions-right">
<button type="button" mat-stroked-button [disabled]="tourStepIndex() === 0" (click)="previousTourStep()">Back</button>
<button type="button" mat-flat-button color="primary" (click)="nextTourStep()">
{{ tourStepIndex() === tourSteps().length - 1 ? 'Finish' : 'Next' }}
</button>
</div>
</div>
</section>
}
</div>

View File

@ -1,31 +1,338 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, HostListener, ViewChild, computed, effect, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { environment } from '@environment';
import { FlowBlock, FlowData } from '@models/flow';
import { EditorSidebar } from "@pages/main/editor-sidebar/editor-sidebar";
import { Flow } from '@models/flow';
import { Authorization } from '@services/authorization/authorization';
import { BlocksService } from '@services/blocks/blocks';
import { FlowsService } from '@services/flows/flows';
import { EditorStateHolder } from '@stores/flow-editor';
import { FlowAssistant } from '@shared/flow-assistant/flow-assistant';
import { TitleToolbar } from "@shared/title-toolbar/title-toolbar";
import { ReteEditor } from "@shared/rete-editor/rete-editor";
import { firstValueFrom } from 'rxjs';
type TourStep = {
id: string;
target: string;
title: string;
description: string;
sidebarSection?: 'flows' | 'blocks' | 'containers';
};
@Component({
selector: 'app-flow-editor',
imports: [EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, MatButtonModule, MatCardModule, MatIconModule],
imports: [CommonModule, EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant, MatButtonModule, MatCardModule, MatIconModule],
templateUrl: './flow-editor.html',
styleUrl: './flow-editor.css',
})
export class FlowEditor {
private static readonly SIDEBAR_OPEN_DELAY_MS = 320;
private static readonly TOUR_SEEN_KEY_PREFIX = 'editor-guided-tour-seen:';
@ViewChild(EditorSidebar) editorSidebar?: EditorSidebar;
private editorState: EditorStateHolder = inject(EditorStateHolder);
private authorization = inject(Authorization);
private flowsService = inject(FlowsService);
private blocksService = inject(BlocksService);
private tourBootstrapped = false;
private demoFlowId: string | null = null;
assistantEnabled = environment.assistantEnabled;
assistantOpen = signal(true);
flow = this.editorState.currentFlow;
readonly = this.editorState.isCurrentFlowReadOnly;
tourActive = signal(false);
tourStepIndex = signal(0);
tourSpotlightStyle = signal<Record<string, string>>({});
tourCardStyle = signal<Record<string, string>>({});
tourSteps = computed<TourStep[]>(() => {
const steps: TourStep[] = [
{
id: 'sidebar-shell',
target: 'editor-sidebar-shell',
title: 'Sidebar',
description: 'This is the editor sidebar. It can stay collapsed, but it is the main entry point for opening flows and adding nodes to the canvas.',
sidebarSection: 'flows'
},
{
id: 'sidebar-flows',
target: 'editor-sidebar-flows',
title: 'Flows',
description: 'Use the Flows section to create, open, delete or clone an existing flow before editing. Flow can be Private or Public. You can only edit Private flows or Public flows that you have created.',
sidebarSection: 'flows'
},
{
id: 'sidebar-blocks',
target: 'editor-sidebar-blocks',
title: 'Blocks',
description: 'Blocks are the standard workflow nodes used to build the flow logic, drag them into the canvas.',
sidebarSection: 'blocks'
},
{
id: 'sidebar-containers',
target: 'editor-sidebar-containers',
title: 'Containers',
description: 'Containers are reusable flow nodes that can embed a subflow and expose a public interface, drag them into the canvas.',
sidebarSection: 'containers'
},
{
id: 'canvas-demo',
target: 'editor-canvas',
title: 'Demo Flow',
description: 'This is the edit window: add blocks, place them on the canvas, and connect outputs to inputs.'
},
{
id: 'save-demo',
target: 'editor-save',
title: 'Save Changes',
description: 'Use Save to persist your flow.'
},
{
id: 'execute-flow',
target: 'editor-execute',
title: 'Execute a Valid Flow',
description: 'Once the flow is completed, saved and valid, the Execute button is automatically enabled and you can execute it.',
}
];
return steps;
});
activeTourStep = computed(() => this.tourSteps()[this.tourStepIndex()] ?? null);
constructor() {
effect(() => {
const username = this.authorization.loggedInUser()?.username ?? null;
if (!username || this.tourBootstrapped) return;
this.tourBootstrapped = true;
queueMicrotask(() => {
void this.startTourIfNeeded();
});
});
effect(() => {
if (!this.tourActive()) return;
this.activeTourStep();
setTimeout(() => this.syncTourLayout());
});
}
toggleAssistant() {
this.assistantOpen.update((value) => !value);
if (this.tourActive()) {
setTimeout(() => this.syncTourLayout());
}
}
@HostListener('window:resize')
onWindowResize() {
if (!this.tourActive()) return;
this.syncTourLayout();
}
previousTourStep() {
if (this.tourStepIndex() <= 0) return;
this.tourStepIndex.update((value) => value - 1);
this.ensureSidebarForStep();
}
async nextTourStep() {
const currentStep = this.activeTourStep();
if (currentStep?.id === 'save-demo') {
await this.saveDemoFlowAndOpenExecutable();
}
if (this.tourStepIndex() >= this.tourSteps().length - 1) {
await this.closeTour(true);
return;
}
this.tourStepIndex.update((value) => value + 1);
this.ensureSidebarForStep();
}
async skipTour() {
await this.closeTour(true);
}
private async startTourIfNeeded() {
const username = this.authorization.loggedInUser()?.username ?? null;
if (!username) return;
if (!environment.tourModeAlwaysOn && this.hasSeenTour(username)) return;
await this.prepareDemoFlow();
this.tourStepIndex.set(0);
this.ensureSidebarForStep();
this.tourActive.set(true);
setTimeout(() => this.syncTourLayout());
}
private async closeTour(_markSeen: boolean) {
const username = this.authorization.loggedInUser()?.username ?? null;
if (_markSeen && username && !environment.tourModeAlwaysOn) {
localStorage.setItem(`${FlowEditor.TOUR_SEEN_KEY_PREFIX}${username}`, 'true');
}
await this.cleanupDemoFlow();
this.tourActive.set(false);
this.tourSpotlightStyle.set({});
this.tourCardStyle.set({});
}
private syncTourLayout() {
const step = this.activeTourStep();
if (!step) return;
const sidebarOpened = this.ensureSidebarForStep();
if (sidebarOpened) {
setTimeout(() => this.syncTourLayout(), FlowEditor.SIDEBAR_OPEN_DELAY_MS);
return;
}
const target = document.querySelector<HTMLElement>(`[data-tour="${step.target}"]`);
if (!target) {
this.nextTourStep();
return;
}
const rect = target.getBoundingClientRect();
const padding = 10;
const spotlightTop = Math.max(8, rect.top - padding);
const spotlightLeft = Math.max(8, rect.left - padding);
const spotlightWidth = Math.max(120, rect.width + padding * 2);
const spotlightHeight = Math.max(56, rect.height + padding * 2);
this.tourSpotlightStyle.set({
top: `${spotlightTop}px`,
left: `${spotlightLeft}px`,
width: `${spotlightWidth}px`,
height: `${spotlightHeight}px`
});
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const rightSideSteps = new Set(['sidebar-shell', 'sidebar-flows', 'sidebar-blocks', 'sidebar-containers']);
const cardWidth = 320;
const preferredLeft = Math.min(
viewportWidth - 340,
rightSideSteps.has(step.id)
? Math.max(16, rect.right + 24)
: Math.max(16, rect.left)
);
const preferredTop = rect.bottom + 18 + 220 <= viewportHeight
? rect.bottom + 18
: Math.max(16, rect.top - 220);
this.tourCardStyle.set({
top: `${preferredTop}px`,
left: `${Math.min(preferredLeft, viewportWidth - cardWidth - 16)}px`
});
}
private async prepareDemoFlow() {
if (this.demoFlowId) return;
const demoFlow = await firstValueFrom(this.flowsService.createNewFlow('Guided Tour Demo'));
this.demoFlowId = demoFlow.id;
await this.editorState.openDocument(demoFlow, { skipDirtyCheck: true });
const demoData = await this.buildDemoFlowData();
this.editorState.updateData(demoData);
}
private async buildDemoFlowData(): Promise<FlowData> {
const firstBlock = await firstValueFrom(this.blocksService.createEmptyBlock('LLMBlock'));
const secondBlock = await firstValueFrom(this.blocksService.createEmptyBlock('LLMBlock'));
const left = this.decorateDemoBlock(firstBlock, 'Collect Prompt', { x: 140, y: 180 });
const right = this.decorateDemoBlock(secondBlock, 'Generate Answer', { x: 520, y: 180 });
const sourceOutput = left.outputs[0]?.name ?? 'output';
const targetInput = right.inputs[0]?.name ?? 'input';
return {
blocks: [left, right],
containers: [],
connections: [
{
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-demo-connection`,
sourceId: left.id,
sourceName: sourceOutput,
targetId: right.id,
targetName: targetInput
}
]
};
}
private decorateDemoBlock(block: FlowBlock, name: string, position: { x: number; y: number }): FlowBlock {
const specificConfiguration: Record<string, unknown> = {
...(block.specificConfiguration ?? {}),
name
};
if (typeof specificConfiguration['prompt'] === 'string') {
specificConfiguration['prompt'] = name === 'Collect Prompt'
? 'Ask for the user topic and prepare a short prompt.'
: 'Generate a short answer using the previous block output.';
}
return {
...block,
name,
position,
specificConfiguration
};
}
private async saveDemoFlowAndOpenExecutable() {
if (this.editorState.isDirty() && this.flow()?.id === this.demoFlowId) {
await firstValueFrom(this.editorState.save());
}
this.editorSidebar?.openSide('flows');
const executableFlow = await this.findExecutableFlow();
if (executableFlow) {
await this.editorState.openDocument(executableFlow, { skipDirtyCheck: true });
}
}
private async findExecutableFlow(): Promise<Flow | null> {
const flowsSignal = await this.flowsService.getAllFlows();
const flows = flowsSignal();
return flows.find((flow) => flow.status === 'EXECUTABLE' && flow.id !== this.demoFlowId) ?? null;
}
private async cleanupDemoFlow() {
if (!this.demoFlowId) return;
const demoFlowId = this.demoFlowId;
this.demoFlowId = null;
if (this.flow()?.id === demoFlowId) {
this.editorState.closeDocument();
}
try {
await firstValueFrom(this.flowsService.deleteFlow(demoFlowId));
} catch (err) {
console.error('Failed to delete guided tour demo flow', err);
}
}
private hasSeenTour(username: string): boolean {
return localStorage.getItem(`${FlowEditor.TOUR_SEEN_KEY_PREFIX}${username}`) === 'true';
}
private ensureSidebarForStep(): boolean {
const section = this.activeTourStep()?.sidebarSection;
if (!section) return false;
if (this.editorSidebar?.collapsed() || this.editorSidebar?.open !== section) {
this.editorSidebar?.openSide(section);
return true;
}
return false;
}
}

View File

@ -1,5 +1,6 @@
<div
class="editor-sidebar-shell transition-all duration-300 ease-in-out overflow-hidden"
data-tour="editor-sidebar-shell"
[class.w-14]="collapsed()"
[class.w-80]="!collapsed()"
>
@ -47,6 +48,7 @@
<div class="editor-sidebar-content h-full min-h-0 min-w-0 overflow-hidden">
@switch (open) {
@case ('flows') {
<div data-tour="editor-sidebar-flows">
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">
@if (!creatingFlow()) {
<i ngProjectAs="header-button"
@ -59,16 +61,21 @@
}
<app-flows-list></app-flows-list>
</app-group-holder>
</div>
}
@case ('blocks') {
<div data-tour="editor-sidebar-blocks">
<app-group-holder title="Blocks" icon="bi-boxes">
<app-blocks-list></app-blocks-list>
</app-group-holder>
</div>
}
@case ('containers') {
<div data-tour="editor-sidebar-containers">
<app-group-holder title="Containers" icon="bi-box-seam">
<app-containers-list></app-containers-list>
</app-group-holder>
</div>
}
}
</div>

View File

@ -48,6 +48,7 @@
<button
type="button"
mat-stroked-button
data-tour="editor-execute"
class="title-toolbar-execute"
[disabled]="executeLoading()"
(click)="execute()">
@ -63,6 +64,7 @@
<button
type="button"
mat-flat-button
data-tour="editor-save"
tabindex="-1"
[disabled]="!canSave()"
(click)="save()">

View File

@ -10,6 +10,7 @@ export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
assistantEnabled: false,
tourModeAlwaysOn: true,
authorizationCallService: AuthorizationCallFakeService, // Assign the appropriate service here
assistantCallService: AssistantCallServiceFake,
flowsCallService: FlowsCallServiceFake,

View File

@ -10,6 +10,7 @@ export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
assistantEnabled: true,
tourModeAlwaysOn: true,
assistantCallService: AssistantCallService,
authorizationCallService: AuthorizationCallService,
flowsCallService: FlowsCallService,

View File

@ -10,6 +10,7 @@ export const environment = {
apiUrl: '/api',
production: true,
assistantEnabled: false,
tourModeAlwaysOn: false,
authorizationCallService: AuthorizationCallService,
assistantCallService: AssistantCallService,
flowsCallService: FlowsCallService,