fixed bug on item list

This commit is contained in:
Lucio Lelii 2026-01-16 12:05:52 +01:00
parent 8e6084c32a
commit 958d3210c5
21 changed files with 466 additions and 114 deletions

View File

@ -1,4 +1,4 @@
<div class="main-layout min-h-screen flex flex-col">
<div class="main-layout h-screen flex flex-col">
<!-- Header fisso -->
<header
@ -33,9 +33,9 @@
</header>
<!-- Wrapper per compensare header fisso -->
<div class="flex-1 pt-16 flex flex-col">
<div class="pt-16 flex flex-col flex-1 h-full overflow-hidden">
<ul class="nav sticky-top top-16 z-40
<ul class="nav fixed w-full top-16 z-40
flex gap-1 px-3
bg-[#d8f4e7] border-b-none shadow-sm">
@ -64,7 +64,7 @@
<!-- Contenuto principale scrollabile -->
<main class="main-content flex-1 overflow-auto p-4 bg-gray-50">
<main class="main-content w-full h-full mt-12 overflow-hidden ml-2 bg-white pb-4 pr-4 rounded-md shadow-sm">
<router-outlet></router-outlet>
</main>

View File

@ -1,23 +1,25 @@
<div class="app-layout min-h-screen flex flex-col">
<div class="flex flex-col flex-1 h-full">
<!-- HEADER -->
<!--<app-header></app-header> -->
<!-- BODY -->
<div class="flex flex-1 overflow-hidden">
<div class="flex flex-row flex-1 overflow-hidden">
<!-- LEFT SIDEBAR -->
<app-editor-sidebar></app-editor-sidebar>
<!-- CENTER EDITOR -->
<main class="flex-1 bg-gray-100 overflow-auto">
<main class="flex flex-col w-full bg-gray-100 overflow-hidden">
@if (flow()) {
<div>
<div class="flex-1 overflow-hidden">
<app-title-toolbar></app-title-toolbar>
<!-- editor canvas -->
<div class="flex-1 bg-amber-200 h-full">
</div>
</div>
} @else {
<div class="text-gray-400 text-center mt-10">
<div class="text-gray-400 text-center mt-10 flex flex-col flex-1">
Select a document
</div>
}

View File

@ -1,9 +1,18 @@
export type FlowVisibility = 'public' | 'private';
export type Flow = {
id: string;
name: string;
visibility: 'public' | 'private';
data: any;
visibility: FlowVisibility;
data: FlowData;
author: string;
createdAt: Date;
updatedAt: Date;
};
};
export class FlowData {
constructor(public nodes: any[] = [], public edges: any[] = []) {}
};

View File

@ -1,55 +1,54 @@
<aside
class="relative h-full
<aside class="h-full flex flex-row
bg-white border-r border-gray-200
transition-all duration-300
overflow-hidden"
[class.w-72]="!collapsed()"
[class.w-14]="collapsed()">
transition-all duration-300 p-1" [class.w-72]="!collapsed()" [class.w-14]="collapsed()">
@if (!collapsed()) {
<button
(click)="collapse()"
class="absolute top-3 right-3
<div class="flex flex-col h-full">
<button (click)="collapse()" class="relative
w-6 h-6 rounded-full
bg-white border shadow
flex items-center justify-center
hover:bg-gray-50 z-10">
hover:bg-gray-50 z-10" [class.invisible]="collapsed()">
<i class="bi bi-chevron-left text-xs transition-transform"></i>
</button>
}
<!-- ICON MODE -->
<div class="flex flex-col gap-4 items-center pt-6 flex-1">
<i class="bi bi-lightning-charge-fill text-indigo-500 text-xl cursor-pointer enabled p-2"
(click)="openSide('flows')"></i>
<i [ngClass]="{'cursor-pointer text-gray-600 enabled' : !blockDisabled()}"
class="bi bi-boxes text-gray-200 text-xl p-2" (click)="!blockDisabled() && openSide('blocks')"></i>
</div>
</div>
@if (!collapsed()) {
<!-- CONTENT -->
<div class="h-full p-4">
@if(!collapsed()) {
<div class="pt-6 space-y-6">
<!-- ACCORDION -->
<div class="h-full bg-white flex-1">
<div class="space-y-6 felx-1">
@switch (open) {
@case ('flows') {
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">
<app-flows-list></app-flows-list>
</app-group-holder>
}
@case ('blocks') {
<app-group-holder title="Blocks" icon="bi-boxes">
<div class="space-y-2">
<div class="block-item">Input</div>
<div class="block-item">Transform</div>
<div class="block-item">Output</div>
</div>
</app-group-holder>
}
@case ('flows') {
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">
<i ngProjectAs="header-button"
class="bi bi-plus-circle-fill text-blue-600 hover:cursor-pointer hover:text-blue-400 text-lg"
(click)="createNewFlow()"></i>
<app-flows-list></app-flows-list>
</app-group-holder>
}
@case ('blocks') {
<app-group-holder title="Blocks" icon="bi-boxes">
<div class="space-y-2 overflow-auto">
<div class="block-item">Input</div>
<div class="block-item">Transform</div>
<div class="block-item">Output</div>
</div>
</app-group-holder>
}
}
</div>
} @else {
<!-- ICON MODE -->
<div class="flex flex-col gap-4 items-center pt-6">
<i class="bi bi-lightning-charge-fill text-indigo-500 text-xl cursor-pointer enabled p-2" (click)="openSide('flows')"></i>
<i [ngClass]="{'cursor-pointer text-gray-600 enabled' : !blockDisabled()}"
class="bi bi-boxes text-gray-200 text-xl p-2" (click)="!blockDisabled() && openSide('blocks')"></i>
</div>
}
</div>
}
</aside>

View File

@ -3,6 +3,7 @@ import { GroupHolder } from '@shared/group-holder/group-holder';
import { FlowsList } from '@shared/flows-list/flows-list';
import { EditorStateHolder } from '@stores/flow-editor';
import { CommonModule } from '@angular/common';
import { FlowsService } from '@services/flows/flows';
type OpenedId = 'flows' | 'blocks';
@ -16,6 +17,8 @@ export class EditorSidebar {
flowState = inject(EditorStateHolder);
flowService = inject(FlowsService);
blockDisabled = computed(() => !this.flowState.hasFlow());
collapsed = signal(true);
@ -31,5 +34,18 @@ export class EditorSidebar {
collapse() {
this.collapsed.set(true);
}
createNewFlow() {
console.log('Creating new flow...');
this.flowService.createNewFlow().subscribe({
next: flow => {
console.log('New flow created:', flow);
this.flowState.openDocument(flow);
},
error: err => {
console.error('Error creating new flow:', err);
},
});
}
}

View File

@ -9,7 +9,7 @@ export abstract class FlowsCallServiceBase {
abstract updateFlow(flow: Flow) : Observable<void>;
abstract createNewFlow() : Observable<Flow>;
abstract createNewFlow(name?: string) : Observable<Flow>;
abstract deleteFlow(flowId: string) : Observable<void>;

View File

@ -1,6 +1,8 @@
import { Flow } from "@models/flow";
import { Flow, FlowData } from "@models/flow";
import { FlowsCallServiceBase } from "./flows-call.base";
import { BehaviorSubject, Observable, of } from "rxjs";
import { Authorization } from "@services/authorization/authorization";
import { inject } from "@angular/core";
export class FlowsCallServiceFake extends FlowsCallServiceBase {
override getFlowById(flowId: string): Observable<Flow> {
@ -11,9 +13,11 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
return of(flow);
}
authorizationService = inject(Authorization);
private data: Record<string, Flow> = {
'1': { id: '1', name: 'Test Flow 1', data: {}, visibility: 'public', author: 'Alice', createdAt: new Date(), updatedAt: new Date() },
'2': { id: '2', name: 'Test Flow 2', data: {}, visibility: 'private', author: 'Bob', createdAt: new Date(), updatedAt: new Date() },
'1': { id: '1', name: 'Test Flow 1', data: new FlowData(), visibility: 'public', author: 'Alice', createdAt: new Date(), updatedAt: new Date() },
'2': { id: '2', name: 'Test Flow 2', data: new FlowData(), visibility: 'private', author: 'Bob', createdAt: new Date(), updatedAt: new Date() },
};
override retrieveAllFlows() {
@ -25,9 +29,9 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
return of(void 0);
}
override createNewFlow(): Observable<Flow> {
override createNewFlow(name?: string): Observable<Flow> {
const newId = (Object.keys(this.data).length + 1).toString();
this.data[newId] = { id: newId, name: 'New Flow', data: {}, visibility: 'private', author: 'Charlie', createdAt: new Date(), updatedAt: new Date() };
this.data[newId] = { id: newId, name: name || `New Flow`, data: new FlowData() , visibility: 'private', author: this.authorizationService.loggedInUser()!.username, createdAt: new Date(), updatedAt: new Date() };
return of(this.data[newId]);
}

View File

@ -6,7 +6,7 @@ export class FlowsCallService extends FlowsCallServiceBase {
override getFlowById(flowId: string): Observable<Flow> {
throw new Error("Method not implemented.");
}
override createNewFlow(): Observable<Flow> {
override createNewFlow(name?: string): Observable<Flow> {
throw new Error("Method not implemented.");
}
override deleteFlow(flowId: string): Observable<void> {

View File

@ -54,12 +54,13 @@ export class FlowsService {
cloneFlow(flowId: string): Observable<void> {
const originalFlow$ = this.flowsCallService.getFlowById(flowId);
const newFlow$ = this.flowsCallService.createNewFlow();
return combineLatest([originalFlow$, newFlow$]).pipe(
switchMap(([originalFlow, newFlow]) => {
newFlow.name = originalFlow.name + ' (Clone)';
newFlow.data = { ...originalFlow.data };
newFlow.name = this.nextFileName(originalFlow.name);
newFlow.data = originalFlow.data ;
return this.flowsCallService.updateFlow(newFlow);
}),
@ -72,8 +73,8 @@ export class FlowsService {
}
createNewFlow() {
return this.flowsCallService.createNewFlow().pipe(
createNewFlow(name? : string) {
return this.flowsCallService.createNewFlow(name || this.nextFileName('New Flow')).pipe(
tap(() => this.refresh()),
catchError(err => {
console.error('Create new flow failed', err);
@ -82,4 +83,19 @@ export class FlowsService {
);
}
private nextFileName(base: string): string {
let n = 0;
while (true) {
const name =
n === 0 ? `${base}` : `${base}(${n})`;
const exists = this._flows().some(flow => flow.name === name);
if (!exists) {
return name;
}
n++;
}
}
}

View File

@ -0,0 +1,77 @@
<div class="group border
px-3 py-2 cursor-pointer
transition
hover:shadow-[0_0_12px_rgba(99,102,241,0.6)]
bg-indigo-50 border-indigo-200 select-none" (click)="toggleDetails()" (dblclick)="open()">
<!-- Header -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2 text-indigo-700 font-medium">
<i class="bi" [ngClass]="{
'bi-globe': flow().visibility === 'public',
'bi-lock-fill': flow().visibility === 'private'
}" [class.text-indigo-500]="flow().visibility === 'public'"
[class.text-gray-500]="flow().visibility === 'private'">
</i>
{{ flow().name }}
</div>
<i class="bi bi-chevron-down text-xs text-indigo-400
transition-transform duration-200" [class.rotate-180]="expanded()">
</i>
</div>
<!-- Dettagli -->
<div class="overflow-hidden transition-all duration-300" [class.max-h-0]="!expanded()"
[class.max-h-36]="expanded()">
<div class="mt-2 pt-2 border-t border-indigo-200
text-xs text-gray-600
flex row items-center gap-1">
<!-- Author -->
<div class="flex items-baseline gap-2">
<span class="text-[11px] tracking-wide text-gray-400">
Author:
</span>
<span class="font-small text-gray-700">
{{ flow().author }}
</span>
</div>
<!-- Created -->
<div class="flex items-baseline gap-2">
<span class="text-[11px] tracking-wide text-gray-400">
Created:
</span>
<span class="font-small text-gray-700">
{{ flow().createdAt | date:'dd/MM/yyyy' }}
</span>
</div>
</div>
<div class="w-full h-px bg-linear-to-r from-transparent via-indigo-200 to-transparent my-3"></div>
<!-- Actions -->
<div class="flex items-center gap-2 text-sm">
<button class="p-1 rounded enabled:hover:bg-indigo-100 enabled:text-indigo-600 disabled:text-indigo-200" title="Open"
[disabled]="this.flow().id == this.openedFlowId()"
(click)="$event.stopPropagation(); open()">
<i class="bi bi-box-arrow-in-right"></i>
</button>
<button class="p-1 rounded enabled:hover:bg-indigo-100 enabled:text-indigo-600 disabled:text-indigo-200" title="Clone"
(click)="$event.stopPropagation(); clone()" [disabled]="this.flow().visibility!=='private'">
<i class="bi bi-files"></i>
</button>
<button class="p-1 rounded enabled:hover:bg-red-100 enabled:text-red-600 disabled:text-red-200" title="Delete"
(click)="$event.stopPropagation(); remove()" [disabled]="this.flow().visibility!=='private'">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FlowItem } from './flow-item';
describe('FlowItem', () => {
let component: FlowItem;
let fixture: ComponentFixture<FlowItem>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FlowItem]
})
.compileComponents();
fixture = TestBed.createComponent(FlowItem);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,72 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, EventEmitter, inject, input, model, output, signal, untracked } from '@angular/core';
import { Flow } from '@models/flow';
import { FlowsService } from '@services/flows/flows';
import { EditorStateHolder } from '@stores/flow-editor';
@Component({
selector: 'app-flow-item',
imports: [CommonModule],
templateUrl: './flow-item.html',
styleUrl: './flow-item.css',
})
export class FlowItem {
private editorState = inject(EditorStateHolder);
private flowsService = inject(FlowsService);
flow = input.required<Flow>();
detailOpenedId = model<string | null>(null);
openedFlowId = computed(() => this.editorState.currentFlow()?.id);
expanded = signal(false);
constructor() {
effect(() => {
if (this.expanded()) {
if (this.detailOpenedId() !== this.flow().id) {
this.expanded.set(false);
}
}
});
}
open() {
console.log('Opening flow:', this.flow());
this.editorState.openDocument(this.flow());
}
clone() {
this.flowsService.cloneFlow(this.flow().id).subscribe({
next: clonedFlow => {
console.log('Flow cloned:', clonedFlow);
},
error: err => console.error('Error cloning flow:', err)
});
}
remove() {
this.flowsService.deleteFlow(this.flow().id).subscribe({
next: () => {
console.log('Flow deleted:', this.flow().id);
if (this.openedFlowId() === this.flow().id) {
this.editorState.closeDocument();
}
},
error: err => console.error('Error deleting flow:', err)
});
}
toggleDetails() {
this.expanded.update(v => !v);
if (this.expanded()) {
this.detailOpenedId.set(this.flow().id);
} else {
this.detailOpenedId.set(null);
}
}
}

View File

@ -1,33 +1,41 @@
@if (loading()) {
<div class="flex items-center justify-center h-32">
<div class="spinner-border text-indigo-500" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
} @else if (filteredFlows.length === 0) {
<div class="btn-group w-full mb-3" role="group">
} @else {
<div class="h-screen flex flex-col">
<div class="btn-group w-full mb-3" role="group">
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'all'"
[class.btn-outline-primary]="filter() !== 'all'" (click)="filter.set('all')">
All
</button>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'all'"
[class.btn-outline-primary]="filter() !== 'all'" (click)="filter.set('all')">
All
</button>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'public'"
[class.btn-outline-primary]="filter() !== 'public'" (click)="filter.set('public')">
Public
</button>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'public'"
[class.btn-outline-primary]="filter() !== 'public'" (click)="filter.set('public')">
Public
</button>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'private'"
[class.btn-outline-primary]="filter() !== 'private'" (click)="filter.set('private')">
Private
</button>
<button type="button" class="btn btn-sm w-full transition" [class.btn-primary]="filter() === 'private'"
[class.btn-outline-primary]="filter() !== 'private'" (click)="filter.set('private')">
Private
</button>
</div>
</div>
<div class="space-y-2">
@for (flow of filteredFlows(); track flow.id) {
<app-flow-item [flow]="flow"></app-flow-item>
}
</div>
}
@if (filteredFlows().length === 0) {
<div class="text-gray-400 text-center mt-10 flex flex-col flex-1 justify-center">
No flows found
</div>
} @else {
<div class="flex-1 overflow-y-auto h-full py-2 gap-2 flex flex-col items-center max-h-[calc(100vh-16rem)]">
@for (flow of filteredFlows(); track flow.id) {
<app-flow-item [(detailOpenedId)]="detailOpenedId" [flow]="flow"></app-flow-item>
}
</div>
}
</div>
}

View File

@ -22,6 +22,8 @@ export class FlowsList {
flows: Signal<Flow[]> | undefined;
detailOpenedId = signal<string | null>(null);
ngOnInit() {
this.flowsService.getAllFlows().then(flowsSignal => {
this.flows = flowsSignal;
@ -30,10 +32,11 @@ export class FlowsList {
}
filteredFlows = computed(() => {
if (!this.flows) return [];
if (this.filter() === 'all') return this.flows();
return this.flows().filter(f => f.visibility === this.filter());
const flows = this.flows?.();
console.log('Filtering flows', flows?.map(f => f.name));
if (!flows) return [];
if (this.filter() === 'all') return flows;
return flows.filter(f => f.visibility === this.filter());
});
}

View File

@ -1,13 +1,13 @@
<div
class="rounded-xl border border-gray-200
class=" border-gray-200
bg-white shadow-sm
transition overflow-hidden">
transition flex-1 h-full">
<!-- Header -->
<div
class="w-full flex items-center justify-between
px-4 py-3
text-sm font-semibold text-gray-700
text-sm font-semibold text-gray-700 user-select-none
bg-gray-50 "
>
@ -15,15 +15,14 @@
<i class="bi {{ icon }} text-lg"></i>
<span>{{ title }}</span>
</div>
<span>
<ng-content select="header-button"></ng-content>
</span>
</div>
<!-- Body -->
<div
class="overflow-hidden transition-all duration-300 max-h-96"
>
<div class="p-3 space-y-2">
class="transition-all duration-300 px-2 pb-2 flex flex-col flex-1 overflow-hidden">
<ng-content></ng-content>
</div>
</div>
</div>

View File

@ -32,36 +32,30 @@
<button tabindex="-1"
class="border-0
flex items-center gap-1
enabled:hover:bg-green-200 text-green-600
enabled:hover:bg-green-200 enabled:text-green-600 disabled:text-green-300
p-1"
title="Save" [disabled]="!notSaved()" (click)="save()">
<i class="bi bi-save"></i>
</button>
<div class="w-px h-5 bg-gray-200 mx-2"></div>
<button tabindex="-1"
class="border-0
flex items-center gap-1
enabled:hover:bg-gray-100 text-gray-600 p-1"
title="Undo">
enabled:hover:bg-gray-100 enabled:text-gray-600 disabled:text-gray-300 p-1"
title="Undo" [disabled]="!editorState.undoEnabled()" (click)="undo()">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
<button tabindex="-1"
class="border-0
flex items-center gap-1
enabled:hover:bg-gray-100 text-gray-600 p-1"
title="Redo">
enabled:hover:bg-gray-100 enabled:text-gray-600 disabled:text-gray-300 p-1"
[disabled]="!editorState.redoEnabled()"
title="Redo" (click)="redo()">
<i class="bi bi-arrow-clockwise"></i>
</button>
<div class="w-px h-5 bg-gray-200 mx-2"></div>
<button tabindex="-1"
class="border-0
flex items-center gap-1
enabled:hover:bg-red-200 text-red-600 p-1"
title="Clear">
<i class="bi bi-trash"></i>
</button>
</div>
</div>

View File

@ -11,7 +11,7 @@ export class TitleToolbar {
@ViewChild('titleInput') myInputRef!: ElementRef;
private editorState: EditorStateHolder = inject(EditorStateHolder);
editorState: EditorStateHolder = inject(EditorStateHolder);
title = computed(() => {
const flow = this.editorState.currentFlow();
return flow ? flow.name : 'No Flow Opened';
@ -41,7 +41,7 @@ export class TitleToolbar {
}
console.log('Updating flow title to:', trimmed);
const flow = this.editorState.currentFlow()!;
this.editorState.updateFlow({ ...flow, name: trimmed });
this.editorState.updateFlowTitle(trimmed );
}
save() {
@ -51,4 +51,11 @@ export class TitleToolbar {
);
}
undo() {
this.editorState.undo();
}
redo() {
this.editorState.redo();
}
}

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { EditorStateHolder as Editor } from './editor';
describe('Editor', () => {
let service: Editor;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(Editor);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,107 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { Flow, FlowData } from '@models/flow';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { FlowsService } from '@services/flows/flows';
import { FlowsCallService } from '@services/flows/flows-call';
import { tap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class EditorStateHolder {
/** Stato */
readonly currentFlow = signal<Flow | null>(null);
readonly isDirty = signal(false);
/** Derived state */
readonly hasFlow = computed(() => !!this.currentFlow());
flowsService: FlowsService = inject(FlowsService);
constructor(private confirm: ConfirmDialogService) { }
previousDataStack : FlowData[] = [];
nextDataStack : FlowData[] = [];
redoEnabled = computed(() => this.nextDataStack.length > 0);
undoEnabled = computed(() => this.previousDataStack.length > 1);
/** Intent: open document */
async openDocument(doc: Flow): Promise<boolean> {
if (this.isDirty()) {
const confirmed = await this.confirm.open(
'You have unsaved changes. Open another document?'
);
if (!confirmed) return false;
}
this.currentFlow.set(doc);
this.isDirty.set(false);
return true;
}
/** Intent: mark editor dirty */
private markDirty() {
this.isDirty.set(true);
}
/** Intent: save document */
private markSaved() {
this.isDirty.set(false);
}
/** Intent: close document */
closeDocument() {
this.currentFlow.set(null);
this.isDirty.set(false);
}
updateData(data: FlowData) {
this.currentFlow.update(flow => {
if (!flow) return flow;
return { ...flow, data };
});
this.previousDataStack.push(data);
this.markDirty();
}
updateFlowTitle(newTitle: string) {
const flow = this.currentFlow();
if (!flow) return;
this.currentFlow.update(flow => {
if (!flow) return flow;
return { ...flow, name: newTitle };
});
this.previousDataStack.push(flow.data);
this.markDirty();
}
save() {
return this.flowsService.updateFlow(this.currentFlow()!).pipe(
tap(() => this.markSaved())
)
}
undo() {
const stack = this.previousDataStack;
if (stack.length < 2) return; // Nothing to undo
// Remove the current state
this.nextDataStack.push(stack.pop()!);
// Get the previous state
const previousData = stack[stack.length - 1];
this.updateData(previousData);
}
redo() {
const stack = this.nextDataStack;
if (stack.length === 0) return; // Nothing to redo
const nextData = stack.pop()!;
this.previousDataStack.push(nextData);
this.updateData(nextData);
}
}

View File