Say what each block is in the palette, from its role

The palette knew two kinds of block, a human step and an automated one,
so a Storage node, an End and a routing block all read 'Automated step'.
It now goes by the role the block's type declares: a resource that is
not a step, an end of a path, an automated or a human decision, a rejoin
of branches, a human step, an automated step - each with its own icon.

The server's BRANCH_REJOIN role is read as the web's MERGE, which it had
never matched, so a rejoin was taken for an ordinary activity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-28 16:00:20 +02:00
parent 672f53d9ab
commit a8b1920fdb
6 changed files with 61 additions and 8 deletions

View File

@ -148,6 +148,7 @@ describe('toNodeCapabilities', () => {
it('reads that a type routes exclusively, which is what lets it guard a loop', () => {
expect(toNodeCapabilities({ visualRole: 'DECISION', routesExclusively: true }).routesExclusively).toBe(true);
expect(toNodeCapabilities({ visualRole: 'RESOURCE' }).visualRole).toBe('RESOURCE');
expect(toNodeCapabilities({ visualRole: 'BRANCH_REJOIN' }).visualRole).toBe('MERGE');
});
it('falls back per-field for missing booleans and an unknown visualRole', () => {

View File

@ -96,7 +96,9 @@ export function toPorts(
const NODE_VISUAL_ROLES: NodeVisualRole[] = ['ACTIVITY', 'DECISION', 'MERGE', 'END', 'CONTAINER', 'RESOURCE'];
function toVisualRole(value: unknown): NodeVisualRole {
const candidate = typeof value === 'string' ? value.toUpperCase() : '';
const raw = typeof value === 'string' ? value.toUpperCase() : '';
// The server calls a rejoin of branches BRANCH_REJOIN; the web has always called it MERGE.
const candidate = raw === 'BRANCH_REJOIN' ? 'MERGE' : raw;
return (NODE_VISUAL_ROLES as string[]).includes(candidate)
? candidate as NodeVisualRole
: DEFAULT_NODE_CAPABILITIES.visualRole;

View File

@ -107,6 +107,19 @@
color: #0ea5e9;
}
.blocks-list-card-kind--resource {
color: #0f766e;
}
.blocks-list-card-kind--end {
color: #475569;
}
.blocks-list-card-kind--decision,
.blocks-list-card-kind--merge {
color: #6366f1;
}
.blocks-list-card-kind--container {
color: #7c3aed;
}

View File

@ -33,16 +33,14 @@
(dragstart)="onDragStart($event, block)">
<div class="blocks-list-card-row">
@let kind = kindOf(block);
<mat-icon
class="blocks-list-card-kind"
[class.blocks-list-card-kind--interactive]="block.userInteractive"
[fontIcon]="block.userInteractive ? 'touch_app' : 'auto_awesome'"></mat-icon>
[class]="'blocks-list-card-kind blocks-list-card-kind--' + kind.modifier"
[fontIcon]="kind.icon"></mat-icon>
<div class="blocks-list-card-meta">
<div class="blocks-list-card-title">{{ block.type }}</div>
<div class="blocks-list-card-sub">
{{ block.userInteractive ? 'Human step' : 'Automated step' }}
</div>
<div class="blocks-list-card-sub">{{ kind.label }}</div>
</div>
<button

View File

@ -2,13 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BlocksService } from '@services/blocks/blocks';
import { ListState } from '@stores/list-state';
import { vi } from 'vitest';
import { BlocksList } from './blocks-list';
import { blockKind, BlocksList } from './blocks-list';
const BLOCK_TYPES = [
{
@ -82,4 +83,16 @@ describe('BlocksList', () => {
expect(component.infoOpenFor()).toBe('SomethingElse');
});
it('says what each block is from the role its type declares, not only whether a person is in it', () => {
const base = { ...DEFAULT_NODE_CAPABILITIES };
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'RESOURCE' } }).label).toBe('Resource · not a step');
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'END', terminal: true } }).label).toBe('End of a path');
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'DECISION', routesExclusively: true } }).label).toBe('Automated decision');
expect(blockKind({ userInteractive: true, capabilities: { ...base, visualRole: 'DECISION', routesExclusively: true } }).label).toBe('Human decision');
expect(blockKind({ userInteractive: true, capabilities: base }).label).toBe('Human step');
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'MERGE' } }).label).toBe('Branches rejoin');
expect(blockKind({ userInteractive: false, capabilities: base }).label).toBe('Automated step');
expect(blockKind({ userInteractive: false }).label).toBe('Automated step');
});
});

View File

@ -14,6 +14,28 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BLOCK_TYPE_DRAG_MIME } from './block-drag';
/** What a block is in a flow, as the palette says it: its icon, its words, its colour. */
export type BlockKind = { icon: string; label: string; modifier: string };
/**
* From the role its type declares, not only whether a person is involved: a storage is not a step
* at all, an end closes a path, a decision picks one branch - none of them is "an automated step".
*/
export function blockKind(block: Pick<BlockType, 'userInteractive' | 'capabilities'>): BlockKind {
const capabilities = block.capabilities;
const decides = capabilities?.visualRole === 'DECISION' || capabilities?.routesExclusively === true;
if (capabilities?.visualRole === 'RESOURCE') return { icon: 'database', label: 'Resource · not a step', modifier: 'resource' };
if (block.userInteractive) {
return decides
? { icon: 'touch_app', label: 'Human decision', modifier: 'interactive' }
: { icon: 'touch_app', label: 'Human step', modifier: 'interactive' };
}
if (capabilities?.visualRole === 'END' || capabilities?.terminal === true) return { icon: 'flag', label: 'End of a path', modifier: 'end' };
if (decides) return { icon: 'call_split', label: 'Automated decision', modifier: 'decision' };
if (capabilities?.visualRole === 'MERGE') return { icon: 'merge', label: 'Branches rejoin', modifier: 'merge' };
return { icon: 'auto_awesome', label: 'Automated step', modifier: 'automated' };
}
@Component({
selector: 'app-blocks-list',
imports: [MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
@ -93,6 +115,10 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
readonly showLoading = computed(() => this.loading() || this.serviceLoading());
kindOf(block: BlockType): BlockKind {
return blockKind(block);
}
onDragStart(event: DragEvent, block: BlockType) {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'copy';