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
1 change: 1 addition & 0 deletions packages/apollo-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"@lexical/table": "0.42.0",
"@lexical/utils": "0.42.0",
"@lingui/core": "^5.6.1",
"@lingui/message-utils": "^5.6.1",
"@lingui/react": "^5.6.1",
"@mui/icons-material": "^5.18.0",
"@mui/material": "^5.18.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { cn } from '@uipath/apollo-wind';
import { CanvasTooltip } from '../CanvasTooltip';
import type { NodeDecoration, NodeDecorationTone } from './JsonTree.types';

const CHIP_TONE_TEXT_CLASS: Record<NodeDecorationTone, string> = {
neutral: 'text-foreground-subtle',
info: 'text-info',
warning: 'text-warning',
error: 'text-error',
};

const CHIP_TONE_BORDER_CLASS: Record<NodeDecorationTone, string> = {
neutral: 'border-border',
info: 'border-info/40',
warning: 'border-warning/40',
error: 'border-error/40',
};

export function DecorationChip({ decoration }: { decoration?: NodeDecoration }) {
const chip = decoration?.chip;
if (!chip || (chip.label == null && chip.icon == null)) return null;
const tone = chip.tone ?? 'neutral';

// A label gets the pill treatment; a bare icon renders unboxed.
const content = chip.label ? (
<span
className={cn(
'flex min-w-0 items-center gap-1 truncate rounded-full border px-1.5 py-px text-[10px] font-medium leading-none',
CHIP_TONE_BORDER_CLASS[tone],
CHIP_TONE_TEXT_CLASS[tone]
)}
>
{chip.icon && <span className="shrink-0">{chip.icon}</span>}
{chip.label}
</span>
) : (
<span
className={cn('flex shrink-0 items-center', CHIP_TONE_TEXT_CLASS[tone])}
// Only an element with an explicit role supports aria-label, so pair
// them: a bare icon with a tooltip becomes a labelled `img`.
{...(chip.tooltip ? { role: 'img' as const, 'aria-label': chip.tooltip } : {})}
>
{chip.icon}
</span>
);

if (!chip.tooltip) return content;
return (
<CanvasTooltip placement="top" content={<span className="text-xs">{chip.tooltip}</span>}>
{content}
</CanvasTooltip>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Button, cn } from '@uipath/apollo-wind';
import { useEffect, useRef } from 'react';
import { useSafeLingui } from '../../../i18n';

/** Apply/Cancel row shared by the multiline editors. */
export function EditorActions({
onApply,
onCancel,
applyDisabled = false,
}: {
onApply: () => void;
onCancel: () => void;
applyDisabled?: boolean;
}) {
const { _ } = useSafeLingui();
return (
<div className="flex items-center gap-1.5">
<Button size="3xs" onClick={onApply} disabled={applyDisabled}>
{_({ id: 'canvas.json_value_panel.apply', message: 'Apply' })}
</Button>
<Button size="3xs" variant="ghost" onClick={onCancel}>
{_({ id: 'canvas.json_value_panel.cancel', message: 'Cancel' })}
</Button>
</div>
);
}

export interface EditorTextareaProps {
value: string;
onChange: (value: string) => void;
/** Bound to Ctrl/Cmd+Enter. */
onApply: () => void;
/** Bound to Escape (contained so a host dialog does not also close). */
onCancel: () => void;
invalid: boolean;
rows: number;
ariaLabel: string;
placeholder?: string;
className?: string;
}

/** Multiline editing surface shared by the leaf and container editors. */
export function EditorTextarea({
value,
onChange,
onApply,
onCancel,
invalid,
rows,
ariaLabel,
placeholder,
className,
}: EditorTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);

// Mounted in response to the user's edit action; focus follows it (the
// attribute form trips a11y linting).
useEffect(() => {
textareaRef.current?.focus();
}, []);

return (
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
// preventDefault (not just stopPropagation): a host that closes on
// Escape via a document-level listener (e.g. Radix Dialog) reads the
// native event's defaultPrevented flag, which crosses the portal
// boundary reliably — React synthetic stopPropagation does not.
e.preventDefault();
e.stopPropagation();
onCancel();
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) onApply();
}}
rows={rows}
spellCheck={false}
aria-label={ariaLabel}
aria-invalid={invalid || undefined}
placeholder={placeholder}
className={cn(
'w-full resize-y rounded-lg border bg-surface p-2 font-mono text-xs leading-5 text-foreground outline-none',
invalid ? 'border-error' : 'border-border-subtle focus:border-brand',
className
)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { cn } from '@uipath/apollo-wind';
import { useState } from 'react';
import { useSafeLingui } from '../../../i18n';
import { EditorActions, EditorTextarea } from './EditorChrome';
import type { JsonTreeNode, JsonValue, RenderCodeEditor } from './JsonTree.types';

export interface JsonContainerEditorProps {
node: JsonTreeNode;
onCommit: (value: JsonValue) => void;
onCancel: () => void;
/**
* Renders the editing surface as a code editor instead of the plain
* textarea. The Apply/Cancel chrome and error message stay owned here.
*/
renderCodeEditor?: RenderCodeEditor;
className?: string;
}

function isValidJson(raw: string): boolean {
try {
JSON.parse(raw);
return true;
} catch {
return false;
}
}

/**
* Multiline JSON editor for object/array nodes. Applies with the Apply button
* or Ctrl/Cmd+Enter; Escape cancels. Parse errors block the commit. Consumers
* can swap the textarea for a real code editor via `renderCodeEditor`.
*/
export function JsonContainerEditor({
node,
onCommit,
onCancel,
renderCodeEditor,
className,
}: JsonContainerEditorProps) {
const { _ } = useSafeLingui();
const initialValue = node.value ?? (node.type === 'array' ? [] : {});
const [raw, setRaw] = useState(() => JSON.stringify(initialValue, null, 2));
const [error, setError] = useState<string | null>(null);

const handleChange = (value: string) => {
setRaw(value);
setError(null);
};

const apply = () => {
try {
onCommit(JSON.parse(raw) as JsonValue);
} catch (e) {
setError(
e instanceof Error
? e.message
: _({
id: 'canvas.json_value_panel.invalid_json',
message: 'Invalid JSON',
})
);
}
};

return (
<div className={cn('flex flex-col gap-1.5', className)}>
{renderCodeEditor ? (
renderCodeEditor({
value: raw,
onChange: handleChange,
onApply: apply,
onCancel,
invalid: !isValidJson(raw),
language: 'json',
autoFocus: true,
})
) : (
<EditorTextarea
value={raw}
onChange={handleChange}
onApply={apply}
onCancel={onCancel}
invalid={!!error}
rows={Math.min(12, Math.max(3, raw.split('\n').length))}
ariaLabel={_({
id: 'canvas.json_value_panel.edit_json_of',
message: 'Edit JSON of {key}',
values: { key: node.key },
})}
/>
)}
{error && <p className="text-xs leading-4 text-error">{error}</p>}
<EditorActions onApply={apply} onCancel={onCancel} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { cn } from '@uipath/apollo-wind';
import { useEffect, useRef, useState } from 'react';
import { useSafeLingui } from '../../../i18n';
import type { JsonTreeNode, JsonValue } from './JsonTree.types';
import { isNumericEdit, parseLeafValue, resolveLeafEditType } from './leafEditTypes';

export interface JsonLeafValueEditorProps {
node: JsonTreeNode;
onCommit: (value: JsonValue) => void;
onCancel: () => void;
className?: string;
}

/**
* Inline editor for scalar leaves (string, number, null). Commits on Enter or
* blur, cancels on Escape. Invalid numbers are flagged and never committed.
*/
export function JsonLeafValueEditor({
node,
onCommit,
onCancel,
className,
}: JsonLeafValueEditorProps) {
const { _ } = useSafeLingui();
const editType = resolveLeafEditType(node.type, node.schema);
const initial =
node.value === undefined || node.value === null
? String(node.schema?.default ?? '')
: String(node.value);
const [raw, setRaw] = useState(initial);
const cancelledRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const invalid = parseLeafValue(raw, editType) === undefined;

// The editor mounts in response to the user clicking the value, so focus
// follows that action (the attribute form trips a11y linting).
useEffect(() => {
inputRef.current?.focus();
}, []);

const commit = () => {
const parsed = parseLeafValue(raw, editType);
if (parsed === undefined) return;
onCommit(parsed);
};

return (
<input
ref={inputRef}
type="text"
value={raw}
inputMode={editType === 'integer' ? 'numeric' : editType === 'number' ? 'decimal' : undefined}
onChange={(e) => setRaw(e.target.value)}
onFocus={(e) => e.target.select()}
onBlur={() => {
if (cancelledRef.current) {
cancelledRef.current = false;
return;
}
if (invalid) onCancel();
else commit();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') {
// Contain Escape here so it cancels the edit without also bubbling
// to a host that closes on Escape (e.g. a dialog or the panel).
// preventDefault sets the native event's defaultPrevented flag, which
// a document-level host listener (Radix Dialog) reads across the
// portal boundary — synthetic stopPropagation alone does not stop it.
e.preventDefault();
e.stopPropagation();
cancelledRef.current = true;
onCancel();
}
}}
aria-label={_({
id: 'canvas.json_value_panel.edit_value_of',
message: 'Edit value of {key}',
values: { key: node.key },
})}
aria-invalid={invalid || undefined}
placeholder={
isNumericEdit(editType)
? _({
id: 'canvas.json_value_panel.enter_number',
message: 'Enter a number',
})
: _({
id: 'canvas.json_value_panel.enter_value',
message: 'Enter a value',
})
}
className={cn(
'min-w-0 flex-1 rounded bg-transparent font-mono text-xs text-foreground outline-none ring-1',
invalid ? 'ring-error' : 'ring-brand',
className
)}
/>
);
}
Loading
Loading