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:
parent
52b26acddd
commit
b60fc3c1d6
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue