Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27356-studio-flow-map-rows-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

fix(studio): a flow node's key / value map puts each field name on its own line, so the name is readable

Clause-②: no
Metadata-protocol: none

- **Before.** In the flow inspector, each row of a key / value map (a Create Record or Update Record node's field values, an HTTP node's headers, an Assignment node's assignments) put the key box, the value box and up to two buttons on one line. In the 300 px inspector the key box showed about nine characters, so a field name read "subj" or "dueD".
- **Now.** A row stacks: the key on its own line beside the remove button, the value on the line under it beside the text / expression toggle where the map has one. The same field name shows about 24 characters. Nothing about what the map stores changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#27356 (hotcrm#2320 Studio S5) — a Create Record node's field
* names were cut to four or five characters ("subj", "dueD"): each map row put
* the field box, the value box and two buttons on one line, so the narrow
* inspector split its width between them. A row now stacks: the field on its
* own line beside the remove button, the value on the line under it beside the
* text / expression toggle.
*
* jsdom draws no layout, so the pin is the row's structure, read through
* `FlowNodeConfigField` (the editor's only caller) on a `create_record` node:
* the field box never shares a line with the value box. The same editor draws
* every key / value map in the flow inspector; the HTTP `headers` map is the
* second case, a map with no value toggle.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';

vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [], loading: false, error: null }),
}));

import { FlowNodeConfigField } from './FlowNodeConfigField';
import type { FlowConfigField } from './flow-node-config';

afterEach(cleanup);

const TOGGLE = 'Write as a CEL expression';
const REMOVE = /remove/i;
const LONG_FIELD = 'customer_satisfaction_score';

function renderMap(field: FlowConfigField, nodeType: string, value: Record<string, unknown>) {
render(
<FlowNodeConfigField
field={field}
value={value}
onCommit={vi.fn()}
context={{ draft: {}, node: { id: 'n1', type: nodeType, config: { objectName: 'case' } } }}
/>,
);
}

/** The line a box sits on: the row's key line or value line. */
const lineOf = (el: HTMLElement) => el.closest('[data-testid^="flow-kv-"]') as HTMLElement;

describe('a key / value map row stacks the field over its value (objectstack#27356)', () => {
it('Create Record: the long field name has its own line, beside the remove button only', () => {
renderMap(
{ id: 'fields', path: ['config', 'fields'], label: 'Field values', kind: 'keyValue' },
'create_record',
{ [LONG_FIELD]: '5' },
);
const key = screen.getByDisplayValue(LONG_FIELD);
const value = screen.getByDisplayValue('5');
const keyLine = lineOf(key);

expect(keyLine.contains(value)).toBe(false);
expect(keyLine.querySelectorAll('input')).toHaveLength(1);
const buttons = [...keyLine.querySelectorAll('button')];
expect(buttons).toHaveLength(1);
expect(buttons[0]!.getAttribute('aria-label')).toMatch(REMOVE);

// The value's line carries the text / expression toggle, under the field.
const valueLine = lineOf(value);
expect(valueLine).not.toBe(keyLine);
expect(valueLine.contains(screen.getByRole('button', { name: TOGGLE }))).toBe(true);
expect(keyLine.compareDocumentPosition(valueLine) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

it('a map with no value toggle (HTTP headers) stacks the same way', () => {
renderMap(
{ id: 'headers', path: ['config', 'headers'], label: 'Headers', kind: 'keyValue' },
'http',
{ 'X-Request-Correlation-Id': 'abc' },
);
const key = screen.getByDisplayValue('X-Request-Correlation-Id');
expect(lineOf(key).contains(screen.getByDisplayValue('abc'))).toBe(false);
expect(screen.queryByRole('button', { name: TOGGLE })).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export function FlowKeyValueField({
{ASSIGNMENT_ARRAY_FORM_PRESCRIPTION}
</p>
)}
<div className="space-y-1.5">
<div className="space-y-3">
{rows.length === 0 && (
<p className="text-[11px] italic text-muted-foreground">{emptyLabel}</p>
)}
Expand All @@ -373,7 +373,13 @@ export function FlowKeyValueField({
const refusal = envelopeSlot && writesMap ? valueRefusal(rowValue(row)) : null;
return (
<div key={row.id} className="space-y-1">
<div className="flex items-center gap-1.5">
{/*
* objectstack#27356 — the key sits on its own line, over its
* value. Side by side, the inspector's width split three ways
* (key, value, buttons) and a field name read "subj" or "dueD";
* on its own line it keeps the full width beside one button.
*/}
<div className="flex items-center gap-1.5" data-testid="flow-kv-key-line">
<Input
list={suggestsKeys ? keyListId : undefined}
value={row.key}
Expand All @@ -384,8 +390,22 @@ export function FlowKeyValueField({
}}
placeholder={keyLabel}
disabled={disabled}
className="h-8 flex-1 font-mono text-xs"
className="h-8 min-w-0 flex-1 font-mono text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0 text-muted-foreground"
onClick={() => removeRow(row.id)}
disabled={disabled}
aria-label={removeLabel}
title={removeLabel}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-1.5" data-testid="flow-kv-value-line">
<VariableTextInput
mode={hole ? 'hole' : 'expression'}
mono={expression}
Expand Down Expand Up @@ -415,18 +435,6 @@ export function FlowKeyValueField({
<Code2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0 text-muted-foreground"
onClick={() => removeRow(row.id)}
disabled={disabled}
aria-label={removeLabel}
title={removeLabel}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{refusal ? (
<div className="space-y-0.5" role="alert">
Expand Down
Loading