Files
bruno/packages/bruno-app/src/components/RequestPane/QueryBuilder/FieldNode.js
2026-04-22 22:24:06 +05:30

531 lines
17 KiB
JavaScript

import React, { useCallback, useState, useMemo, useRef } from 'react';
import { IconChevronRight, IconChevronDown, IconTrash, IconInfoCircle } from '@tabler/icons';
import { nanoid } from 'nanoid';
import { getInputObjectFields } from 'utils/graphql/queryBuilder';
const ListArgValueInput = ({ values, onChange, field, indent }) => {
const [items, setItems] = useState(() => {
const vals = Array.isArray(values) ? values : (values ? [values] : []);
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
return [...mapped, { id: nanoid(), value: '' }];
});
const lastExternalRef = useRef(values);
// Sync internal items when values prop changes externally (e.g. editor edits)
if (values !== lastExternalRef.current) {
lastExternalRef.current = values;
const vals = Array.isArray(values) ? values : (values ? [values] : []);
const filledValues = items.filter((i) => i.value !== '').map((i) => i.value);
if (JSON.stringify(vals) !== JSON.stringify(filledValues)) {
const mapped = vals.map((v) => ({ id: nanoid(), value: v }));
setItems([...mapped, { id: nanoid(), value: '' }]);
}
}
const handleItemChange = (id, newValue) => {
let nextItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item));
const lastItem = nextItems[nextItems.length - 1];
if (lastItem && lastItem.value !== '') {
nextItems = [...nextItems, { id: nanoid(), value: '' }];
}
setItems(nextItems);
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
};
const handleRemove = (id) => {
const nextItems = items.filter((item) => item.id !== id);
setItems(nextItems);
onChange(nextItems.filter((item) => item.value !== '').map((item) => item.value));
};
return (
<div>
{items.map((item, index) => {
const isEmptyRow = index === items.length - 1 && item.value === '';
return (
<div key={item.id} className="arg-row" style={{ paddingLeft: indent }} onClick={(e) => e.stopPropagation()}>
<ArgValueInput value={item.value} onChange={(v) => handleItemChange(item.id, v)} field={field} />
{isEmptyRow ? (
<span className="list-arg-remove-spacer" />
) : (
<button
type="button"
className="list-arg-remove"
onClick={(e) => {
e.stopPropagation();
handleRemove(item.id);
}}
aria-label="Remove item"
>
<IconTrash size={13} strokeWidth={1.5} />
</button>
)}
</div>
);
})}
</div>
);
};
const ArgValueInput = ({ value, onChange, field }) => {
if (field.isEnum && field.enumValues) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
<option value="">Select option</option>
{field.enumValues.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
);
}
if (field.isBoolean) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} onClick={(e) => e.stopPropagation()}>
<option value="">Select option</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
);
}
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Enter value"
className="mousetrap"
/>
);
};
const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues, enabledArgs, onToggleInputField, onSetInputFieldValue }) => {
const [expandedFields, setExpandedFields] = useState(new Set());
const fields = useMemo(() => getInputObjectFields(namedType), [namedType]);
if (!fields || fields.length === 0) return null;
return fields.map((field) => {
const fieldKey = `${parentKey}.${field.name}`;
const isEnabled = enabledArgs ? enabledArgs.has(fieldKey) : false;
const isExpanded = expandedFields.has(field.name);
const value = argValues.get(fieldKey) ?? '';
const toggleExpand = (e) => {
e.stopPropagation();
setExpandedFields((prev) => {
const next = new Set(prev);
if (next.has(field.name)) next.delete(field.name);
else next.add(field.name);
return next;
});
};
const isListOfInputObject = field.isList && field.isInputObject;
const isExpandable = field.isInputObject && !isListOfInputObject;
return (
<React.Fragment key={field.name}>
<div className="arg-row" style={{ paddingLeft: indent }} onClick={isExpandable ? toggleExpand : (e) => e.stopPropagation()}>
{isExpandable ? (
<button type="button" className="field-chevron input-object-chevron" onClick={toggleExpand} aria-label={isExpanded ? 'Collapse' : 'Expand'}>
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</button>
) : (
<span className="input-object-chevron-spacer" />
)}
<input
type="checkbox"
className="field-checkbox mousetrap"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
const willEnable = !isEnabled;
onToggleInputField(fieldKey, fieldPath);
if (isExpandable && willEnable) {
setExpandedFields((prev) => {
const next = new Set(prev);
next.add(field.name);
return next;
});
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{field.name}</span>
{field.isRequired && <span className="arg-required">!</span>}
{(!isEnabled || field.isInputObject) && <span className="field-type">{field.typeLabel}</span>}
{isListOfInputObject && (
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
<IconInfoCircle size={13} strokeWidth={1.5} />
</span>
)}
{!field.isInputObject && isEnabled && (
<ArgValueInput value={value} onChange={(v) => onSetInputFieldValue(fieldKey, v)} field={field} />
)}
</div>
{isExpandable && isExpanded && (
<InputObjectFields
namedType={field.namedType}
parentKey={fieldKey}
fieldPath={fieldPath}
indent={indent + 20}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
)}
</React.Fragment>
);
});
};
const FieldNode = ({
field,
depth,
isChecked,
isExpanded,
onToggleCheck,
onToggleExpand,
argValues,
enabledArgs,
onToggleArg,
onArgChange,
onToggleInputField,
onSetInputFieldValue,
hasChildren
}) => {
const indent = depth * 20;
const handleCheck = useCallback(
(e) => {
e.stopPropagation();
onToggleCheck(field.path, field);
},
[field, onToggleCheck]
);
const hasArgs = field.args && field.args.length > 0;
const canExpand = !field.isLeaf || hasArgs;
const handleExpand = useCallback(
(e) => {
e.stopPropagation();
if (canExpand) {
onToggleExpand(field.path);
}
},
[field.path, canExpand, onToggleExpand]
);
// Union member type row (e.g. "... on Human")
if (field.isUnionMember) {
return (
<div
className="field-node"
role="treeitem"
aria-expanded={isExpanded}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
<span className="field-chevron">
{isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="union-label">... on {field.name}</span>
</div>
);
}
const showSections = isExpanded && (hasArgs || hasChildren);
const sectionIndent = (depth + 1) * 20;
return (
<>
<div
className="field-node"
role="treeitem"
aria-expanded={canExpand ? isExpanded : undefined}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
<span className="field-chevron">
{canExpand ? (
isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)
) : null}
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="field-name">{field.name}</span>
<span className="field-separator">:</span>
<span className="field-type">{field.typeLabel}</span>
</div>
{showSections && hasArgs && (
<>
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
ARGUMENTS
</div>
{field.args.map((arg) => {
const argKey = `${field.path}.${arg.name}`;
const isArgEnabled = enabledArgs ? enabledArgs.has(argKey) : false;
const argValue = argValues.get(argKey) ?? '';
// List of input objects: show unsupported message
if (arg.isList && arg.isInputObject) {
return (
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
<span className="list-complex-unsupported" title="List arguments for complex types are not currently supported.">
<IconInfoCircle size={13} strokeWidth={1.5} />
</span>
</div>
);
}
// Input object arg: render as expandable with children
if (arg.isInputObject) {
return (
<InputObjectArgRow
key={arg.name}
arg={arg}
argKey={argKey}
fieldPath={field.path}
isArgEnabled={isArgEnabled}
sectionIndent={sectionIndent}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleArg={onToggleArg}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
);
}
if (arg.isList && !arg.isInputObject) {
return (
<ListArgRow
key={arg.name}
arg={arg}
fieldPath={field.path}
isArgEnabled={isArgEnabled}
argValue={argValue}
sectionIndent={sectionIndent}
onToggleArg={onToggleArg}
onArgChange={onArgChange}
/>
);
}
return (
<div key={arg.name} className="arg-row" style={{ paddingLeft: sectionIndent + 8 }} onClick={(e) => e.stopPropagation()}>
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
{!isArgEnabled && <span className="field-type">{arg.typeLabel}</span>}
{isArgEnabled && (
<ArgValueInput value={argValue} onChange={(v) => onArgChange(field.path, arg.name, v)} field={arg} />
)}
</div>
);
})}
</>
)}
{showSections && hasChildren && hasArgs && (
<div className="section-header" style={{ paddingLeft: sectionIndent }}>
FIELDS
</div>
)}
</>
);
};
const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent, argValues, enabledArgs, onToggleArg, onToggleInputField, onSetInputFieldValue }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = (e) => {
e.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleCheck = (e) => {
e.stopPropagation();
const willEnable = !isArgEnabled;
onToggleArg && onToggleArg(fieldPath, arg.name);
// Auto-expand when checking only
if (willEnable) {
setIsExpanded(true);
}
};
return (
<>
<div
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
>
<span className="field-chevron input-object-chevron">
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
</div>
{isExpanded && arg.namedType && (
<InputObjectFields
namedType={arg.namedType}
parentKey={argKey}
fieldPath={fieldPath}
indent={sectionIndent + 28}
argValues={argValues}
enabledArgs={enabledArgs}
onToggleInputField={onToggleInputField}
onSetInputFieldValue={onSetInputFieldValue}
/>
)}
</>
);
};
const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onToggleArg, onArgChange }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = (e) => {
e.stopPropagation();
setIsExpanded((prev) => !prev);
};
const handleCheck = (e) => {
e.stopPropagation();
const willEnable = !isArgEnabled;
onToggleArg && onToggleArg(fieldPath, arg.name);
if (willEnable) {
setIsExpanded(true);
}
};
return (
<>
<div
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
>
<span className="field-chevron input-object-chevron">
{isExpanded ? (
<IconChevronDown size={12} strokeWidth={2} />
) : (
<IconChevronRight size={12} strokeWidth={2} />
)}
</span>
<input
type="checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
/>
<span className="arg-name">{arg.name}</span>
{arg.isRequired && <span className="arg-required">!</span>}
<span className="field-type">{arg.typeLabel}</span>
</div>
{isExpanded && (
<ListArgValueInput
values={argValue}
onChange={(v) => onArgChange(fieldPath, arg.name, v)}
field={arg}
indent={sectionIndent + 28}
/>
)}
</>
);
};
export default React.memo(FieldNode);