From 40100431d5e29c5e3f70c8bd2111e4d5faeb8e18 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Sun, 14 Jun 2026 11:48:33 -0700 Subject: [PATCH] feat(shared-ui): add variable picker affordance to config fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved variables were only discoverable by typing ${ — the env-var suggestion popover never appeared otherwise. Add a small ${} button to the config field widgets (BaseInputTemplate, TextareaWidget, ApiKeyWidget) that opens the full list of saved variables and inserts ${ROCKETRIDE_NAME} on select, reusing the existing suggestion list and insert logic. On ApiKeyWidget the button shows only while the field is editable (an existing key stays masked + read-only until cleared via the trash button). Shown only when variables exist (envKeys). Works in web and VS Code since the widgets are shared. --- .../api-key-widget/ApiKeyWidget.tsx | 32 ++++++++++++++++-- .../base-input-template/BaseInputTemplate.tsx | 33 +++++++++++++++++++ .../hooks/useEnvVarAutocomplete.ts | 19 ++++++++++- .../textarea-widget/TextareaWidget.tsx | 33 +++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/api-key-widget/ApiKeyWidget.tsx b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/api-key-widget/ApiKeyWidget.tsx index 03341db6b..f0829dc61 100644 --- a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/api-key-widget/ApiKeyWidget.tsx +++ b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/api-key-widget/ApiKeyWidget.tsx @@ -23,7 +23,7 @@ import { useEffect, useRef, useState, useCallback, KeyboardEvent, FC } from 'react'; import { WidgetProps } from '@rjsf/utils'; -import { InputAdornment, TextField, Tooltip } from '@mui/material'; +import { IconButton, InputAdornment, TextField, Tooltip } from '@mui/material'; import { Delete } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; @@ -110,6 +110,18 @@ const ApiKeyWidget: FC = ({ id, value, label, required, autofocus, [autocomplete, onEnvVarSelect], ); + // Open the full variable list from the picker button (no `${` typing needed). + const handleOpenPicker = useCallback(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + autocomplete.openAll(el, el.selectionStart ?? String(tempValue ?? '').length); + }, [autocomplete, tempValue]); + + // Only offer the picker while the field is editable (an existing key is masked + // + read-only; the user clears it via the trash button before entering a new one). + const showVarPicker = envKeys.length > 0 && !disabled && !readonly && !maskApiKey; + // When in masked mode, scroll the input to the end so the visible trailing characters are shown useEffect(() => { if (inputRef.current && maskApiKey) { @@ -147,7 +159,7 @@ const ApiKeyWidget: FC = ({ id, value, label, required, autofocus, slotProps={{ input: { readOnly: maskApiKey || readonly, - endAdornment: maskApiKey && !readonly && ( + endAdornment: maskApiKey && !readonly ? ( = ({ id, value, label, required, autofocus, /> - ), + ) : showVarPicker ? ( + + + {'${}'} + + + ) : undefined, }, }} /> diff --git a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/base-input-template/BaseInputTemplate.tsx b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/base-input-template/BaseInputTemplate.tsx index 46371de6a..8a6e155c4 100644 --- a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/base-input-template/BaseInputTemplate.tsx +++ b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/base-input-template/BaseInputTemplate.tsx @@ -23,6 +23,8 @@ import { ChangeEvent, FocusEvent, useState, useEffect, useCallback, useRef, KeyboardEvent } from 'react'; import TextField, { TextFieldProps } from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import IconButton from '@mui/material/IconButton'; import { ariaDescribedByIds, BaseInputTemplateProps, examplesId, getInputProps, labelValue, FormContextType, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils'; import { useEnvVarAutocomplete } from '../hooks/useEnvVarAutocomplete'; @@ -125,6 +127,16 @@ export default function BaseInputTemplate< [autocomplete, onEnvVarSelect], ); + // Open the full variable list from the picker button (no `${` typing needed). + const handleOpenPicker = useCallback(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + autocomplete.openAll(el, el.selectionStart ?? String(controlledValue ?? '').length); + }, [autocomplete, controlledValue]); + + const showVarPicker = envKeys.length > 0 && !disabled && !readonly; + // Sync controlled value when the form re-renders with a new prop value (e.g., reset or external update) useEffect(() => { if (value !== undefined && value !== null) { @@ -195,6 +207,27 @@ export default function BaseInputTemplate< inputRef={inputRef} InputLabelProps={DisplayInputLabelProps} {...(textFieldProps as TextFieldProps)} + InputProps={{ + ...(textFieldProps as TextFieldProps).InputProps, + endAdornment: showVarPicker ? ( + + + {'${}'} + + {(textFieldProps as TextFieldProps).InputProps?.endAdornment} + + ) : ( + (textFieldProps as TextFieldProps).InputProps?.endAdornment + ), + }} sx={{ ...sx, '& input[type="number"]::-webkit-outer-spin-button, & input[type="number"]::-webkit-inner-spin-button': { diff --git a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/hooks/useEnvVarAutocomplete.ts b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/hooks/useEnvVarAutocomplete.ts index 81ed8ae47..7ddb6623f 100644 --- a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/hooks/useEnvVarAutocomplete.ts +++ b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/hooks/useEnvVarAutocomplete.ts @@ -19,6 +19,8 @@ export interface UseEnvVarAutocompleteResult { anchorEl: HTMLElement | null; /** Called on every input change to detect the `${` trigger. Pass the value and cursor position directly from the event target. */ handleInputChange: (value: string, cursorPos: number, anchorElement: HTMLElement | null) => void; + /** Opens the full list of available keys (no `${` needed), inserting at `cursorPos` on select. Used by the explicit picker button. */ + openAll: (anchorElement: HTMLElement | null, cursorPos: number) => void; /** Called when the user selects a suggestion. Returns the new field value. */ handleSelect: (key: string, currentValue: string, inputEl: HTMLInputElement | HTMLTextAreaElement | null) => string; /** Dismisses the popover. */ @@ -110,6 +112,21 @@ export function useEnvVarAutocomplete(envKeys: string[]): UseEnvVarAutocompleteR [], ); + const openAll = useCallback( + (anchorElement: HTMLElement | null, cursorPos: number) => { + if (!anchorElement || !envKeys.length) return; + // No `${` was typed — insert at the current cursor position. handleSelect + // replaces from triggerStart to the cursor, so a zero-width range there + // just inserts `${KEY}` without clobbering surrounding text. + triggerStartRef.current = cursorPos; + setSuggestions(envKeys); + setAnchorEl(anchorElement); + setHighlightedIndex(0); + setIsOpen(true); + }, + [envKeys], + ); + const handleDismiss = useCallback(() => { setIsOpen(false); }, []); @@ -124,5 +141,5 @@ export function useEnvVarAutocomplete(envKeys: string[]): UseEnvVarAutocompleteR [suggestions.length], ); - return { isOpen, suggestions, anchorEl, handleInputChange, handleSelect, handleDismiss, highlightedIndex, moveHighlight }; + return { isOpen, suggestions, anchorEl, handleInputChange, openAll, handleSelect, handleDismiss, highlightedIndex, moveHighlight }; } diff --git a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/textarea-widget/TextareaWidget.tsx b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/textarea-widget/TextareaWidget.tsx index f4c324b15..86d2fce50 100644 --- a/packages/shared-ui/src/components/canvas/components/rjsf-widgets/textarea-widget/TextareaWidget.tsx +++ b/packages/shared-ui/src/components/canvas/components/rjsf-widgets/textarea-widget/TextareaWidget.tsx @@ -6,6 +6,8 @@ import { ChangeEvent, FocusEvent, useState, useEffect, useCallback, useRef, KeyboardEvent, FC } from 'react'; import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import IconButton from '@mui/material/IconButton'; import { WidgetProps } from '@rjsf/utils'; import { useEnvVarAutocomplete } from '../hooks/useEnvVarAutocomplete'; @@ -41,6 +43,16 @@ const TextareaWidget: FC = ({ id, value, label, required, autofocus [autocomplete, controlledValue, onChange, options.emptyValue], ); + // Open the full variable list from the picker button (no `${` typing needed). + const handleOpenPicker = useCallback(() => { + const el = inputRef.current; + if (!el) return; + el.focus(); + autocomplete.openAll(el, el.selectionStart ?? String(controlledValue ?? '').length); + }, [autocomplete, controlledValue]); + + const showVarPicker = envKeys.length > 0 && !disabled && !readonly; + const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (!autocomplete.isOpen) return; @@ -90,6 +102,27 @@ const TextareaWidget: FC = ({ id, value, label, required, autofocus disabled={disabled || readonly} error={!!rawErrors?.length} variant="outlined" + InputProps={ + showVarPicker + ? { + endAdornment: ( + + + {'${}'} + + + ), + } + : undefined + } InputLabelProps={{ shrink: true }} helperText={typeof options?.description === 'string' ? options.description : schema?.description} />