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
5 changes: 5 additions & 0 deletions .changeset/flow-picker-insert-tests.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

Flow builder data-picker (#1934): the cursor-insertion math is extracted into a pure `insertToken` helper with unit tests (alongside `formatToken`) — bare CEL vs `{var}` template insertion, append / mid-string / selection-replace, and clamping a reversed or out-of-range selection. Pure refactor, no behavior change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { formatToken, insertToken } from './VariableTextInput';

describe('formatToken', () => {
it('inserts a bare token in expression fields (ADR-0032)', () => {
expect(formatToken('discount_pct', 'expression')).toBe('discount_pct');
expect(formatToken('record.amount', 'expression')).toBe('record.amount');
});
it('wraps a token in single braces in template fields', () => {
expect(formatToken('discount_pct', 'template')).toBe('{discount_pct}');
expect(formatToken('record.amount', 'template')).toBe('{record.amount}');
});
});

describe('insertToken (cursor splice)', () => {
it('appends at the caret (end of value) — expression mode, no braces', () => {
const r = insertToken('lead_score >= ', 'expression', 'record.amount', 14, 14);
expect(r.next).toBe('lead_score >= record.amount');
expect(r.caret).toBe('lead_score >= record.amount'.length);
});
it('inserts mid-string at the caret', () => {
// caret between "a " and "b": "a | b"
const r = insertToken('a b', 'expression', 'x', 2, 2);
expect(r.next).toBe('a x b');
expect(r.caret).toBe(3);
});
it('replaces the current selection', () => {
const r = insertToken('hello OLD world', 'expression', 'NEW', 6, 9);
expect(r.next).toBe('hello NEW world');
});
it('wraps with braces in template mode', () => {
const r = insertToken('Hi ', 'template', 'record.name', 3, 3);
expect(r.next).toBe('Hi {record.name}');
expect(r.caret).toBe('Hi {record.name}'.length);
});
it('clamps out-of-range / reversed selections instead of corrupting the value', () => {
expect(insertToken('abc', 'expression', 'X', 99, 99).next).toBe('abcX');
expect(insertToken('abc', 'expression', 'X', 3, 1).next).toBe('aX'); // reversed [3,1] → selects 'bc', replaced
expect(insertToken('', 'template', 'v', -5, -5).next).toBe('{v}');
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,28 @@ export function formatToken(token: string, mode: VariableFieldMode): string {
return mode === 'template' ? `{${token}}` : token;
}

/**
* Splice a reference into `value` at the selection `[selStart, selEnd]`, in the
* brace mode for the field, returning the new string and the caret position
* just after the inserted token. Pure (the DOM caret restore lives in the
* component); selection bounds are clamped and order-normalized so a reversed or
* out-of-range selection can't corrupt the value.
*/
export function insertToken(
value: string,
mode: VariableFieldMode,
token: string,
selStart: number,
selEnd: number,
): { next: string; caret: number } {
const text = formatToken(token, mode);
const a = Math.min(Math.max(selStart, 0), value.length);
const b = Math.min(Math.max(selEnd, 0), value.length);
const lo = Math.min(a, b);
const hi = Math.max(a, b);
return { next: value.slice(0, lo) + text + value.slice(hi), caret: lo + text.length };
}

export interface VariableTextInputProps {
value: string;
onValueChange: (v: string) => void;
Expand DownExpand Up@@ -95,18 +117,14 @@ export function VariableTextInput({
};

const insert = (token: string) => {
const text = formatToken(token, mode);
const start = Math.min(caret.current.start, value.length);
const end = Math.min(caret.current.end, value.length);
const next = value.slice(0, start) + text + value.slice(end);
const { next, caret: pos } = insertToken(value, mode, token, caret.current.start, caret.current.end);
onValueChange(next);
setOpen(false);
// Restore focus + place the caret just after the inserted token.
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
const pos = start + text.length;
try {
el.setSelectionRange(pos, pos);
} catch {
Expand Down
Loading