Show a node's list parameters closed, with how many items they hold
Every list parameter of a node now reads as its name and a count, and opens on demand to its items; with none there is nothing to open, the add button is the way in, and the "No items" line is gone. An item added opens the list, so it is there to see. The list is one shared component, app-node-list-field, rather than two near-identical blocks in the block node and a third in the execution view: the same list reads and behaves the same everywhere, and a container that gains a list parameter takes the component as it is. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4a526ebbd
commit
aa3f54bb1a
|
|
@ -371,62 +371,15 @@
|
|||
</button>
|
||||
}
|
||||
@if (item.arrayField; as arrayField) {
|
||||
<div class="llm-array-block llm-param-chip llm-param-chip-wide">
|
||||
<div class="llm-param-row-head">
|
||||
<div class="llm-param-key">{{ arrayField.label }}</div>
|
||||
@if (!isReadonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
[attr.title]="'Add ' + arrayField.label.toLowerCase()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="addArrayItem(arrayField.path, $event)">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (!arrayField.items.length) {
|
||||
<div class="llm-array-empty">No items</div>
|
||||
} @else {
|
||||
@for (entry of arrayField.items; track entry.index) {
|
||||
<div class="llm-array-item">
|
||||
<span class="llm-array-item-summary">{{ entry.summary }}</span>
|
||||
<div class="llm-array-item-actions">
|
||||
@if (entry.definition; as definition) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
title="View content"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="viewItemDefinition(definition, $event)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
}
|
||||
@if (!isReadonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
title="Edit item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="editArrayItem(arrayField.path, entry.index, $event)">
|
||||
<i class="bi bi-pen"></i>
|
||||
</button>
|
||||
}
|
||||
@if (!isReadonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn llm-array-remove-btn"
|
||||
title="Remove item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="removeArrayItem(arrayField.path, entry.index, $event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<app-node-list-field
|
||||
class="llm-array-block llm-param-chip llm-param-chip-wide"
|
||||
[label]="arrayField.label"
|
||||
[items]="arrayField.items"
|
||||
[readonly]="isReadonly"
|
||||
(add)="addArrayItem(arrayField.path)"
|
||||
(edit)="editArrayItem(arrayField.path, $event)"
|
||||
(remove)="removeArrayItem(arrayField.path, $event)"
|
||||
(view)="viewItemDefinition($any($event))" />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -527,62 +480,15 @@
|
|||
</button>
|
||||
}
|
||||
@if (item.arrayField; as arrayField) {
|
||||
<div class="llm-array-block llm-param-chip llm-param-chip-wide">
|
||||
<div class="llm-param-row-head">
|
||||
<div class="llm-param-key">{{ arrayField.label }}</div>
|
||||
@if (!isReadonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
[attr.title]="'Add ' + arrayField.label.toLowerCase()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="addArrayItem(arrayField.path, $event)">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (!arrayField.items.length) {
|
||||
<div class="llm-array-empty">No items</div>
|
||||
} @else {
|
||||
@for (entry of arrayField.items; track entry.index) {
|
||||
<div class="llm-array-item">
|
||||
<span class="llm-array-item-summary">{{ entry.summary }}</span>
|
||||
<div class="llm-array-item-actions">
|
||||
@if (entry.definition; as definition) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
title="View content"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="viewItemDefinition(definition, $event)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
}
|
||||
@if (!isReadonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
title="Edit item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="editArrayItem(arrayField.path, entry.index, $event)">
|
||||
<i class="bi bi-pen"></i>
|
||||
</button>
|
||||
}
|
||||
@if (!isReadonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn llm-array-remove-btn"
|
||||
title="Remove item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="removeArrayItem(arrayField.path, entry.index, $event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<app-node-list-field
|
||||
class="llm-array-block llm-param-chip llm-param-chip-wide"
|
||||
[label]="arrayField.label"
|
||||
[items]="arrayField.items"
|
||||
[readonly]="isReadonly"
|
||||
(add)="addArrayItem(arrayField.path)"
|
||||
(edit)="editArrayItem(arrayField.path, $event)"
|
||||
(remove)="removeArrayItem(arrayField.path, $event)"
|
||||
(view)="viewItemDefinition($any($event))" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
|
||||
-->
|
||||
|
||||
<div class="llm-param-row-head">
|
||||
<button
|
||||
type="button"
|
||||
class="llm-array-toggle"
|
||||
[disabled]="!items.length"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.title]="items.length ? (expanded() ? 'Hide ' : 'Show ') + label.toLowerCase() : null"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="toggle($event)">
|
||||
<i class="bi llm-array-chevron" [class.bi-chevron-down]="expanded()" [class.bi-chevron-right]="!expanded()"></i>
|
||||
<span class="llm-param-key">{{ label }}</span>
|
||||
<span class="llm-array-count">{{ items.length }}</span>
|
||||
</button>
|
||||
@if (!readonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
[attr.title]="'Add ' + label.toLowerCase()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="emit(add, undefined, $event)">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (expanded() && items.length) {
|
||||
@for (entry of items; track entry.index) {
|
||||
<div class="llm-array-item">
|
||||
<span class="llm-array-item-summary">{{ entry.summary }}</span>
|
||||
<div class="llm-array-item-actions">
|
||||
@if (entry.definition) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
title="View content"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="emit(view, entry.definition, $event)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
}
|
||||
@if (!readonly) {
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
title="Edit item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="emit(edit, entry.index, $event)">
|
||||
<i class="bi bi-pen"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn llm-array-remove-btn"
|
||||
title="Remove item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="emit(remove, entry.index, $event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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<NodeListFieldComponent>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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.
|
||||
*
|
||||
* <p>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<void>();
|
||||
@Output() readonly edit = new EventEmitter<number>();
|
||||
@Output() readonly remove = new EventEmitter<number>();
|
||||
@Output() readonly view = new EventEmitter<unknown>();
|
||||
|
||||
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<T>(emitter: EventEmitter<T>, value: T, event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
emitter.emit(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -406,16 +406,7 @@
|
|||
<div class="llm-array-sections">
|
||||
@for (arrayField of arrayFields; track arrayField.path) {
|
||||
@if (arrayField.items.length) {
|
||||
<div class="llm-array-block">
|
||||
<div class="llm-param-row-head">
|
||||
<div class="llm-param-key">{{ arrayField.label }}</div>
|
||||
</div>
|
||||
@for (item of arrayField.items; track item.index) {
|
||||
<div class="llm-array-item">
|
||||
<span class="llm-array-item-summary">{{ item.summary }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-node-list-field class="llm-array-block" [label]="arrayField.label" [items]="arrayField.items" [readonly]="true" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue