Show each kind of store as a resource of its own

The resource palette now lists an S3 storage and a PostgreSQL database
as two cards, each with the label and icon its type declares, and the
node on the canvas takes the same. A step's resource field names a
kind, so a storage operation links to either store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-29 09:09:47 +02:00
parent 52b26acddd
commit b60fc3c1d6
11 changed files with 131 additions and 67 deletions

View File

@ -241,9 +241,15 @@ export type FlowResource = {
laneId?: string | null;
};
/** What the editor is told about a kind of resource, as BlockType says about a kind of block. */
/** What the editor is told about a type of resource, as BlockType says about a type of block. */
export type ResourceType = {
type: string;
/** What a step links to: an S3 storage and a PostgreSQL database are both of the Storage kind. */
kind: string;
/** How the editor names it. */
label: string;
/** A Bootstrap icon class, or null for the default one. */
icon?: string | null;
description: string;
exampleEndpoint?: string | null;
configurationType: string | null;
@ -322,20 +328,20 @@ export const FLOW_RESOURCE_PORT_KEY = '__resource';
export const FLOW_RESOURCE_LINK_PORT_KEY = '__resourceRef';
export const FLOW_RESOURCE_SOCKET_TYPE = '__FLOW_RESOURCE__';
/** The schema flag that says a field holds the id of a resource, and of which type. */
/** The schema flag that says a field holds the id of a resource, and of which kind. */
export const RESOURCE_REF_SCHEMA_KEY = 'x-resource-ref';
/**
* The field a block links a resource through, from its schema: its name and the type of resource
* The field a block links a resource through, from its schema: its name and the kind of resource
* it takes. A block has at most one today; the first is the one the canvas draws.
*/
export function resourceRefField(schema: BlockTypeSchema | undefined | null): { field: string; resourceType: string } | null {
export function resourceRefField(schema: BlockTypeSchema | undefined | null): { field: string; resourceKind: string } | null {
const properties = (schema as Record<string, unknown> | null | undefined)?.['properties'];
if (!properties || typeof properties !== 'object') return null;
for (const [field, raw] of Object.entries(properties as Record<string, unknown>)) {
const resourceType = (raw as Record<string, unknown> | null)?.[RESOURCE_REF_SCHEMA_KEY];
if (typeof resourceType === 'string' && resourceType) {
return { field, resourceType };
const resourceKind = (raw as Record<string, unknown> | null)?.[RESOURCE_REF_SCHEMA_KEY];
if (typeof resourceKind === 'string' && resourceKind) {
return { field, resourceKind };
}
}
return null;

View File

@ -6,32 +6,56 @@ import { FlowResource, ResourceType } from '@models/flow';
import { Observable, of } from 'rxjs';
import { ResourcesCallServiceBase } from './resource-call.base';
export const FAKE_STORAGE_RESOURCE_TYPE: ResourceType = {
type: 'Storage',
description: 'A storage the flow keeps data in. It is not a step.',
exampleEndpoint: '/resources/types/Storage/example',
configurationType: 'StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.workflow.manager.storage.StorageResourceConfiguration',
export const FAKE_S3_STORAGE_RESOURCE_TYPE: ResourceType = {
type: 'S3Storage',
kind: 'Storage',
label: 'S3 storage',
icon: 'bi-bucket',
description: 'An S3-compatible object store the flow keeps files in. It is not a step.',
exampleEndpoint: '/resources/types/S3Storage/example',
configurationType: 'S3StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.workflow.manager.storage.s3.S3StorageResourceConfiguration',
schema: {
type: 'object',
properties: {
name: { type: 'string', 'x-ui-order': 10 },
storageType: { type: 'string', 'x-ui-order': 20, 'x-ui-label': 'Storage type' },
source: { type: 'string', enum: ['CATALOG', 'PERSONAL'], default: 'CATALOG', 'x-ui-order': 30 }
source: { type: 'string', enum: ['CATALOG', 'PERSONAL'], default: 'CATALOG', 'x-ui-order': 15 },
scope: { type: 'string', enum: ['SHARED', 'PER_EXECUTION'], default: 'SHARED', 'x-ui-order': 30 }
}
}
};
export const FAKE_POSTGRES_STORAGE_RESOURCE_TYPE: ResourceType = {
type: 'PostgresStorage',
kind: 'Storage',
label: 'PostgreSQL database',
icon: 'bi-database',
description: 'A PostgreSQL database the flow keeps rows in. It is not a step.',
exampleEndpoint: '/resources/types/PostgresStorage/example',
configurationType: 'PostgresStorageResourceConfiguration',
configurationClass: 'it.cnr.isti.workflow.manager.storage.postgres.PostgresStorageResourceConfiguration',
schema: {
type: 'object',
properties: {
name: { type: 'string', 'x-ui-order': 10 },
source: { type: 'string', enum: ['CATALOG', 'PERSONAL'], default: 'CATALOG', 'x-ui-order': 15 },
initScript: { type: 'string', 'x-ui-order': 50 }
}
}
};
export class ResourcesCallServiceFake extends ResourcesCallServiceBase {
override retrieveAllResourceTypes(): Observable<ResourceType[]> {
return of([FAKE_STORAGE_RESOURCE_TYPE]);
return of([FAKE_S3_STORAGE_RESOURCE_TYPE, FAKE_POSTGRES_STORAGE_RESOURCE_TYPE]);
}
override createEmptyResource(resourceType: string): Observable<FlowResource> {
const type = [FAKE_S3_STORAGE_RESOURCE_TYPE, FAKE_POSTGRES_STORAGE_RESOURCE_TYPE]
.find((candidate) => candidate.type === resourceType);
return of({
id: crypto.randomUUID(),
name: resourceType,
specificConfiguration: { type: 'StorageResourceConfiguration', name: resourceType } as FlowResource['specificConfiguration'],
name: type?.label ?? resourceType,
specificConfiguration: { type: type?.configurationType ?? resourceType, name: type?.label ?? resourceType } as FlowResource['specificConfiguration'],
typeName: resourceType,
nodeFamily: 'resource' as const
});

View File

@ -29,31 +29,36 @@ describe('ResourcesCallService', () => {
http.expectOne(`${environment.apiUrl}/resources/types/configurations/catalog`).flush({
sharedDefinitions: { Shape: { type: 'string' } },
descriptors: [{
type: 'Storage',
description: 'A storage the flow keeps data in.',
exampleEndpoint: '/resources/types/Storage/example',
configurationType: 'StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.StorageResourceConfiguration',
type: 'S3Storage',
kind: 'Storage',
label: 'S3 storage',
icon: 'bi-bucket',
description: 'An object store the flow keeps files in.',
exampleEndpoint: '/resources/types/S3Storage/example',
configurationType: 'S3StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.S3StorageResourceConfiguration',
schema: { type: 'object', properties: { name: { type: 'string' } } }
}]
});
const [storage] = await types;
expect(storage.type).toBe('Storage');
expect(storage.configurationType).toBe('StorageResourceConfiguration');
expect(storage).toEqual(expect.objectContaining({
type: 'S3Storage', kind: 'Storage', label: 'S3 storage', icon: 'bi-bucket',
configurationType: 'S3StorageResourceConfiguration'
}));
expect((storage.schema as any)['sharedDefinitions']).toEqual({ Shape: { type: 'string' } });
});
it('asks the server for an empty resource, and marks it as one', async () => {
const resource = firstValueFrom(service.createEmptyResource('Storage'));
const resource = firstValueFrom(service.createEmptyResource('S3Storage'));
http.expectOne(`${environment.apiUrl}/resources/types/Storage/example`).flush({
http.expectOne(`${environment.apiUrl}/resources/types/S3Storage/example`).flush({
id: 'r1',
name: 'Storage',
typeName: 'Storage',
specificConfiguration: { type: 'StorageResourceConfiguration', name: 'Storage' }
name: 'S3 storage',
typeName: 'S3Storage',
specificConfiguration: { type: 'S3StorageResourceConfiguration', name: 'S3 storage' }
});
expect(await resource).toEqual(expect.objectContaining({ id: 'r1', typeName: 'Storage', nodeFamily: 'resource' }));
expect(await resource).toEqual(expect.objectContaining({ id: 'r1', typeName: 'S3Storage', nodeFamily: 'resource' }));
});
});

View File

@ -39,6 +39,9 @@ export class ResourcesCallService extends ResourcesCallServiceBase {
const value = toRecord(raw);
return {
type: String(value['type'] ?? ''),
kind: String(value['kind'] ?? value['type'] ?? ''),
label: String(value['label'] ?? value['type'] ?? ''),
icon: toNullableString(value['icon']),
description: String(value['description'] ?? ''),
exampleEndpoint: toApiPath(value['exampleEndpoint']),
configurationType: toNullableString(value['configurationType']),

View File

@ -26,7 +26,7 @@
}
<div class="llm-header" [class.cursor-move]="!isReadonly">
<div class="llm-icon">
<i class="bi bi-hdd-stack"></i>
<i class="bi" [ngClass]="resourceIcon()"></i>
</div>
<div class="llm-header-content">
<span class="llm-title">{{ resourceTitle() }}</span>

View File

@ -57,6 +57,12 @@ export class ResourceNodeComponent extends GenericNodeComponent {
/** The type of resource, as the palette names it: what a block's title would say. */
resourceTitle(): string {
return String(this.data?.data?.typeName ?? 'Resource');
const typeName = String(this.data?.data?.typeName ?? '');
return this.resourcesService.peekResourceType(typeName)?.label ?? (typeName || 'Resource');
}
/** Its type's own icon, so an object store and a database look different at a glance. */
resourceIcon(): string {
return this.resourcesService.peekResourceType(String(this.data?.data?.typeName ?? ''))?.icon ?? 'bi-hdd-stack';
}
}

View File

@ -28,16 +28,16 @@
<mat-card
class="blocks-list-card blocks-list-card--resource"
draggable="true"
[matTooltip]="'Drag ' + resource.type + ' onto the canvas'"
[matTooltip]="'Drag ' + resource.label + ' onto the canvas'"
matTooltipShowDelay="600"
(dragstart)="onDragStart($event, resource)">
<div class="blocks-list-card-row">
<mat-icon class="blocks-list-card-kind blocks-list-card-kind--resource" fontIcon="storage"></mat-icon>
<i class="bi blocks-list-card-kind blocks-list-card-kind--resource" [ngClass]="resource.icon ?? 'bi-hdd-stack'" aria-hidden="true"></i>
<div class="blocks-list-card-meta">
<div class="blocks-list-card-title">{{ resource.type }}</div>
<div class="blocks-list-card-sub">Resource</div>
<div class="blocks-list-card-title">{{ resource.label }}</div>
<div class="blocks-list-card-sub">{{ resource.kind }} resource</div>
</div>
<button
@ -45,7 +45,7 @@
class="blocks-list-card-info"
[class.blocks-list-card-info--open]="infoOpenFor() === resource.type"
[attr.aria-expanded]="infoOpenFor() === resource.type"
[attr.aria-label]="'What ' + resource.type + ' does'"
[attr.aria-label]="'What ' + resource.label + ' does'"
(pointerdown)="$event.stopPropagation()"
(click)="toggleInfo(resource.type, $event)">
<mat-icon fontIcon="info_outline"></mat-icon>

View File

@ -14,11 +14,25 @@ import { ResourcesList } from './resources-list';
const RESOURCE_TYPES: ResourceType[] = [
{
type: 'Storage',
description: 'A storage the flow keeps data in. It is not a step.',
exampleEndpoint: '/resources/types/Storage/example',
configurationType: 'StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.StorageResourceConfiguration',
type: 'S3Storage',
kind: 'Storage',
label: 'S3 storage',
icon: 'bi-bucket',
description: 'An object store the flow keeps files in. It is not a step.',
exampleEndpoint: '/resources/types/S3Storage/example',
configurationType: 'S3StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.S3StorageResourceConfiguration',
schema: { type: 'object' }
},
{
type: 'PostgresStorage',
kind: 'Storage',
label: 'PostgreSQL database',
icon: 'bi-database',
description: 'A database the flow keeps rows in. It is not a step.',
exampleEndpoint: '/resources/types/PostgresStorage/example',
configurationType: 'PostgresStorageResourceConfiguration',
configurationClass: 'it.cnr.isti.PostgresStorageResourceConfiguration',
schema: { type: 'object' }
}
];
@ -50,11 +64,13 @@ describe('ResourcesList', () => {
await fixture.whenStable();
});
it('lists the resource types, each card draggable onto the canvas', () => {
const card = fixture.nativeElement.querySelector('.blocks-list-card--resource');
expect(card).not.toBeNull();
expect(card.getAttribute('draggable')).toBe('true');
expect(card.textContent).toContain('Storage');
it('lists each store as a card of its own, named and drawn as its type says', () => {
const cards = [...fixture.nativeElement.querySelectorAll('.blocks-list-card--resource')] as HTMLElement[];
expect(cards.map((card) => card.querySelector('.blocks-list-card-title')?.textContent?.trim()))
.toEqual(['S3 storage', 'PostgreSQL database']);
expect(cards.every((card) => card.getAttribute('draggable') === 'true')).toBe(true);
expect(cards[0].querySelector('.bi-bucket')).not.toBeNull();
expect(cards[1].querySelector('.bi-database')).not.toBeNull();
});
it('drags the same payload a block does, saying it is a resource so the drop knows what to ask for', () => {
@ -66,18 +82,18 @@ describe('ResourcesList', () => {
component.onDragStart(event, RESOURCE_TYPES[0]);
expect(JSON.parse(data.get(BLOCK_TYPE_DRAG_MIME)!)).toEqual(expect.objectContaining({
type: 'Storage',
type: 'S3Storage',
family: 'resource',
configurationType: 'StorageResourceConfiguration'
configurationType: 'S3StorageResourceConfiguration'
}));
expect(data.get('text/plain')).toBe('Storage');
expect(data.get('text/plain')).toBe('S3Storage');
});
it('narrows to what the search asks for', () => {
component.searchTerm.set('postgres');
expect(component.filteredResources()).toEqual([]);
component.searchTerm.set('postgresql');
expect(component.filteredResources().map((resource) => resource.type)).toEqual(['PostgresStorage']);
component.searchTerm.set('not a step');
expect(component.filteredResources().map((resource) => resource.type)).toEqual(['Storage']);
component.searchTerm.set('files');
expect(component.filteredResources().map((resource) => resource.type)).toEqual(['S3Storage']);
});
});

View File

@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, model, signal, Si
import { BlockType, ResourceType } from '@models/flow';
import { ResourcesService } from '@services/resources/resources';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { NgClass } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -17,7 +18,7 @@ import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
@Component({
selector: 'app-resources-list',
imports: [FormsModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
imports: [NgClass, FormsModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './resources-list.html',
styleUrl: './resources-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -80,7 +81,8 @@ export class ResourcesList extends ListStateViewHolder<ResourceType> {
const term = this.searchTerm().toLowerCase();
return resources.filter((resource) =>
resource.type.toLowerCase().includes(term) || resource.description.toLowerCase().includes(term)
resource.label.toLowerCase().includes(term) || resource.type.toLowerCase().includes(term)
|| resource.description.toLowerCase().includes(term)
);
});

View File

@ -76,13 +76,13 @@ describe('isResourceNode', () => {
it('is what the node is, not what its type can do: a resource is never among the flow\'s blocks', () => {
const runtime = makeRuntime();
expect(isResourceNode(runtime, makeNode('Storage', 'resource'))).toBe(true);
expect(isResourceNode(runtime, makeNode('S3Storage', 'resource'))).toBe(true);
expect(isResourceNode(runtime, makeNode('LLMBlock', 'block'))).toBe(false);
expect(isResourceNode(undefined, makeNode('Storage', 'resource'))).toBe(true);
expect(isResourceNode(undefined, makeNode('S3Storage', 'resource'))).toBe(true);
});
it('gives a resource the capabilities of one: no connections, no dependencies', () => {
expect(resolveNodeCapabilities(makeRuntime(), makeNode('Storage', 'resource')))
expect(resolveNodeCapabilities(makeRuntime(), makeNode('S3Storage', 'resource')))
.toEqual(RESOURCE_NODE_CAPABILITIES);
});
});
@ -96,8 +96,8 @@ describe('the link between a resource and a step that uses it', () => {
const socket = new ClassicPreset.Socket('__FLOW_RESOURCE__');
const storage = new ClassicPreset.Node('Storage') as any;
storage.data = { id: 'store-1', name: 'notes', typeName: 'Storage', nodeFamily: 'resource',
specificConfiguration: { type: 'StorageResourceConfiguration', name: 'notes' } };
storage.data = { id: 'store-1', name: 'notes', typeName: 'S3Storage', nodeFamily: 'resource',
specificConfiguration: { type: 'S3StorageResourceConfiguration', name: 'notes' } };
storage.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket));
const operation = new ClassicPreset.Node('StorageOperation') as any;
operation.data = { id: 'op-1', typeName: 'StorageOperation', nodeFamily: 'block',
@ -115,7 +115,7 @@ describe('the link between a resource and a step that uses it', () => {
expect(graph.blocks.map((block) => block.id)).toEqual(['op-1']);
expect(graph.resources.map((resource) => resource.id)).toEqual(['store-1']);
expect(graph.resources[0].typeName).toBe('Storage');
expect(graph.resources[0].typeName).toBe('S3Storage');
expect(operationOf(graph)?.['storageNode']).toBe('store-1');
expect(graph.connections).toEqual([]);
expect(graph.dependencies).toEqual([]);

View File

@ -158,11 +158,13 @@ export async function createEditor(
const connectionKind = getGraphConnectionKind(context.data.sourceOutput, context.data.targetInput);
if (connectionKind === "resource") {
// From a resource to a step that uses one, of the kind that step's field asks for. A step
// uses one resource, so a new link replaces the one it had, as a data input keeps to one.
// From a resource to a step that uses one, of the kind that step's field asks for - any
// store for a storage operation. A step uses one resource, so a new link replaces the one it
// had, as a data input keeps to one.
if (!isResourceNode(runtime, sourceNode)) return;
const reference = resourceRefOf(runtime, targetNode);
if (!reference || reference.resourceType !== sourceNode?.data?.typeName) return;
const sourceKind = runtime.resourcesService.peekResourceType(String(sourceNode?.data?.typeName ?? ''))?.kind;
if (!reference || reference.resourceKind !== sourceKind) return;
if (!runtime.restoringConnections) await replaceResourceLink(editor, context.data.target);
return context;
}
@ -272,7 +274,7 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
name: String(blockData?.['name'] ?? node.label),
position: blockData?.['position'],
specificConfiguration: cloneValue(blockData?.['specificConfiguration'] ?? {}),
typeName: String(blockData?.['typeName'] ?? 'Storage'),
typeName: String(blockData?.['typeName'] ?? ''),
nodeFamily: 'resource',
laneId: typeof blockData?.['laneId'] === 'string' ? blockData['laneId'] : null
});