diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index ccea647..c1dff1b 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -371,62 +371,15 @@ } @if (item.arrayField; as arrayField) { -
-
-
{{ arrayField.label }}
- @if (!isReadonly) { - - } -
- @if (!arrayField.items.length) { -
No items
- } @else { - @for (entry of arrayField.items; track entry.index) { -
- {{ entry.summary }} -
- @if (entry.definition; as definition) { - - } - @if (!isReadonly) { - - } - @if (!isReadonly) { - - } -
-
- } - } -
+ } } } @@ -527,62 +480,15 @@ } @if (item.arrayField; as arrayField) { -
-
-
{{ arrayField.label }}
- @if (!isReadonly) { - - } -
- @if (!arrayField.items.length) { -
No items
- } @else { - @for (entry of arrayField.items; track entry.index) { -
- {{ entry.summary }} -
- @if (entry.definition; as definition) { - - } - @if (!isReadonly) { - - } - @if (!isReadonly) { - - } -
-
- } - } -
+ } } diff --git a/src/app/shared/nodes/generic-node/generic-node.spec.ts b/src/app/shared/nodes/generic-node/generic-node.spec.ts index eb89ded..b8408b9 100644 --- a/src/app/shared/nodes/generic-node/generic-node.spec.ts +++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts @@ -519,6 +519,11 @@ describe('GenericNodeComponent', () => { } ]; component.cdr.detectChanges(); + // A list starts closed, showing only its count; opened, its rows offer the button. + for (const toggle of fixture.nativeElement.querySelectorAll('.llm-array-toggle')) { + (toggle as HTMLButtonElement).click(); + } + fixture.detectChanges(); const buttons = fixture.nativeElement.querySelectorAll('[title="View content"]'); expect(buttons.length).toBe(2); }); diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index f92bcc6..2d6a521 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -73,6 +73,7 @@ import { primitiveItemType, type PrimitiveItemType } from '../schema-driven-fields'; +import { NodeListFieldComponent } from '../node-list-field/node-list-field'; type FieldType = SchemaFieldType; @@ -151,7 +152,7 @@ type RenderedSocketPort = { @Component({ selector: 'app-generic-node', - imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent], + imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent, NodeListFieldComponent], templateUrl: './generic-node.html', styleUrl: './generic-node.css', host: { diff --git a/src/app/shared/nodes/node-list-field/node-list-field.css b/src/app/shared/nodes/node-list-field/node-list-field.css new file mode 100644 index 0000000..d7cc41b --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.css @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR + * SPDX-License-Identifier: AGPL-3.0-or-later + * Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + */ + +:host { + display: block; +} + +.llm-param-row-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px; + min-width: 0; +} + +.llm-edit-btn { + border: none; + background: transparent; + color: #64748b; + border-radius: 999px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease; +} + +.llm-edit-btn:hover { + color: #2563eb; + background: rgba(37, 99, 235, 0.1); + transform: translateY(-1px); +} + +.llm-edit-btn:active { + transform: translateY(0); +} + +.llm-edit-btn:disabled { + opacity: 0.45; + cursor: default; + pointer-events: none; +} + +/* A list shows its size first and its items on demand; with none there is nothing to open. */ +.llm-array-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.llm-array-toggle:disabled { + cursor: default; +} + +.llm-array-toggle .llm-param-key { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.llm-array-chevron { + flex: 0 0 auto; + color: #64748b; + font-size: 10px; +} + +.llm-array-toggle:disabled .llm-array-chevron { + opacity: 0.35; +} + +.llm-array-count { + flex: 0 0 auto; + min-width: 18px; + padding: 1px 6px; + border-radius: 999px; + background: #e0e7ff; + color: #3730a3; + font-size: 10px; + font-weight: 600; + line-height: 1.4; + text-align: center; +} + +.llm-array-toggle:disabled .llm-array-count { + background: #f1f5f9; + color: #94a3b8; +} diff --git a/src/app/shared/nodes/node-list-field/node-list-field.html b/src/app/shared/nodes/node-list-field/node-list-field.html new file mode 100644 index 0000000..85d8550 --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.html @@ -0,0 +1,67 @@ + + +
+ + @if (!readonly) { + + } +
+@if (expanded() && items.length) { + @for (entry of items; track entry.index) { +
+ {{ entry.summary }} +
+ @if (entry.definition) { + + } + @if (!readonly) { + + + } +
+
+ } +} diff --git a/src/app/shared/nodes/node-list-field/node-list-field.spec.ts b/src/app/shared/nodes/node-list-field/node-list-field.spec.ts new file mode 100644 index 0000000..26a4f6d --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.spec.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NodeListFieldComponent, NodeListFieldItem } from './node-list-field'; + +describe('NodeListFieldComponent', () => { + let fixture: ComponentFixture; + const item = (index: number, summary: string): NodeListFieldItem => ({ index, summary }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [NodeListFieldComponent] }).compileComponents(); + fixture = TestBed.createComponent(NodeListFieldComponent); + fixture.componentRef.setInput('label', 'Skills'); + }); + + const el = () => fixture.nativeElement as HTMLElement; + const toggle = () => el().querySelector('.llm-array-toggle') as HTMLButtonElement; + + it('starts closed, showing how many items there are', () => { + fixture.componentRef.setInput('items', [item(0, 'a'), item(1, 'b')]); + fixture.detectChanges(); + + expect(el().querySelector('.llm-array-count')?.textContent?.trim()).toBe('2'); + expect(el().querySelectorAll('.llm-array-item').length).toBe(0); + + toggle().click(); + fixture.detectChanges(); + expect(el().querySelectorAll('.llm-array-item').length).toBe(2); + }); + + it('has nothing to open when empty, and says nothing about it', () => { + fixture.componentRef.setInput('items', []); + fixture.detectChanges(); + + expect(toggle().disabled).toBe(true); + expect(el().querySelector('.llm-array-count')?.textContent?.trim()).toBe('0'); + expect(el().textContent).not.toContain('No items'); + expect(el().querySelector('[title="Add skills"]')).not.toBeNull(); + }); + + it('opens when an item is added, so the new one is there to see', () => { + fixture.componentRef.setInput('items', []); + fixture.detectChanges(); + + fixture.componentRef.setInput('items', [item(0, 'mcp-context-economy')]); + fixture.detectChanges(); + + expect(el().querySelector('.llm-array-item-summary')?.textContent).toContain('mcp-context-economy'); + }); + + it('read-only, shows the items and nothing to change them with', () => { + fixture.componentRef.setInput('items', [item(0, 'a')]); + fixture.componentRef.setInput('readonly', true); + fixture.detectChanges(); + toggle().click(); + fixture.detectChanges(); + + expect(el().querySelector('[title="Add skills"]')).toBeNull(); + expect(el().querySelector('[title="Edit item"]')).toBeNull(); + expect(el().querySelector('[title="Remove item"]')).toBeNull(); + }); +}); diff --git a/src/app/shared/nodes/node-list-field/node-list-field.ts b/src/app/shared/nodes/node-list-field/node-list-field.ts new file mode 100644 index 0000000..ece95f9 --- /dev/null +++ b/src/app/shared/nodes/node-list-field/node-list-field.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, signal } from '@angular/core'; + +export type NodeListFieldItem = { + index: number; + summary: string; + /** Something the item can be opened to read in full, when it has one. */ + definition?: unknown | null; +}; + +/** + * A node parameter that is a list: its name and how many items it has, the items themselves on + * demand. Shared by every node that shows lists - blocks, the execution view, and containers once + * one has a list parameter - so they all read and behave the same. + * + *

Starts closed. With no items there is nothing to open, and the add button is the way in; an + * item added opens the list, so what was just added is there to see rather than only counted. + */ +@Component({ + selector: 'app-node-list-field', + standalone: true, + templateUrl: './node-list-field.html', + styleUrl: './node-list-field.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NodeListFieldComponent implements OnChanges { + @Input({ required: true }) label = ''; + @Input() items: NodeListFieldItem[] = []; + /** Read-only views - an execution - show the items and offer nothing to change them. */ + @Input() readonly = false; + + @Output() readonly add = new EventEmitter(); + @Output() readonly edit = new EventEmitter(); + @Output() readonly remove = new EventEmitter(); + @Output() readonly view = new EventEmitter(); + + readonly expanded = signal(false); + + ngOnChanges(changes: SimpleChanges): void { + const change = changes['items']; + if (!change || change.firstChange) return; + const before = Array.isArray(change.previousValue) ? change.previousValue.length : 0; + const after = this.items?.length ?? 0; + if (after > before) this.expanded.set(true); + if (after === 0) this.expanded.set(false); + } + + toggle(event: Event) { + event.preventDefault(); + event.stopPropagation(); + if (!this.items.length) return; + this.expanded.update((open) => !open); + } + + emit(emitter: EventEmitter, value: T, event: Event) { + event.preventDefault(); + event.stopPropagation(); + emitter.emit(value); + } +} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 17b7f3a..445b322 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -406,16 +406,7 @@

@for (arrayField of arrayFields; track arrayField.path) { @if (arrayField.items.length) { -
-
-
{{ arrayField.label }}
-
- @for (item of arrayField.items; track item.index) { -
- {{ item.summary }} -
- } -
+ } }
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index bbc4dec..bf99854 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. +import { NodeListFieldComponent } from '../node-list-field/node-list-field'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject, viewChild } from '@angular/core'; import { ClassicPreset } from 'rete'; @@ -87,7 +88,7 @@ type FieldUiMeta = { @Component({ selector: 'app-task-step-node', - imports: [CommonModule, ReteModule, BiasAnnotationsComponent], + imports: [CommonModule, ReteModule, BiasAnnotationsComponent, NodeListFieldComponent], templateUrl: './task-step-node.html', styleUrl: './task-step-node.css', host: {