Click env var to edit AND improve input/editor ref handling

This commit is contained in:
Gregory Schier
2025-11-01 08:39:07 -07:00
parent 2bcf67aaa6
commit 6ad4e7bbb5
19 changed files with 372 additions and 302 deletions

View File

@@ -403,6 +403,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
<div className="h-full w-[400px] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
<div className="px-2 w-full">
<PlainInput
autoFocus
hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">

View File

@@ -1,12 +1,13 @@
import { useAtomValue } from 'jotai';
import React from 'react';
import type { ComponentType } from 'react';
import React, { useCallback } from 'react';
import { dialogsAtom, hideDialog } from '../lib/dialog';
import { Dialog, type DialogProps } from './core/Dialog';
import { ErrorBoundary } from './ErrorBoundary';
export type DialogInstance = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
render: ComponentType<{ hide: () => void }>;
} & Omit<DialogProps, 'open' | 'children'>;
export function Dialogs() {
@@ -20,19 +21,20 @@ export function Dialogs() {
);
}
function DialogInstance({ render, onClose, id, ...props }: DialogInstance) {
const children = render({ hide: () => hideDialog(id) });
function DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {
const hide = useCallback(() => {
hideDialog(id);
}, [id]);
const handleClose = useCallback(() => {
onClose?.();
hideDialog(id);
}, [id, onClose]);
return (
<ErrorBoundary name={`Dialog ${id}`}>
<Dialog
open
onClose={() => {
onClose?.();
hideDialog(id);
}}
{...props}
>
{children}
<Dialog open onClose={handleClose} {...props}>
<Component hide={hide} {...props} />
</Dialog>
</ErrorBoundary>
);

View File

@@ -1,13 +1,10 @@
import type { Environment, Workspace } from '@yaakapp-internal/models';
import type { Environment, EnvironmentVariable, Workspace } from '@yaakapp-internal/models';
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import {
environmentsBreakdownAtom,
useEnvironmentsBreakdown,
} from '../hooks/useEnvironmentsBreakdown';
import { environmentsBreakdownAtom, useEnvironmentsBreakdown, } from '../hooks/useEnvironmentsBreakdown';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment } from '../lib/model_util';
@@ -28,15 +25,16 @@ import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
interface Props {
initialEnvironment: Environment | null;
initialEnvironmentId: string | null;
addOrFocusVariable?: EnvironmentVariable;
}
type TreeModel = Environment | Workspace;
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
export function EnvironmentEditDialog({ initialEnvironmentId, addOrFocusVariable }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
initialEnvironmentId ?? null,
);
const selectedEnvironment =
@@ -76,16 +74,21 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
</Banner>
</div>
) : (
<EnvironmentEditor className="pl-4 pt-3" environment={selectedEnvironment} />
<EnvironmentEditor
className="pl-4 pt-3"
environment={selectedEnvironment}
addOrFocusVariable={addOrFocusVariable}
/>
)}
</div>
)}
/>
);
};
}
const sharableTooltip = (
<IconTooltip
tabIndex={-1}
icon="eye"
iconSize="sm"
content="This environment will be included in Directory Sync and data exports"

View File

@@ -1,4 +1,4 @@
import type { Environment } from '@yaakapp-internal/models';
import type { Environment, EnvironmentVariable } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
@@ -17,21 +17,20 @@ import { BadgeButton } from './core/BadgeButton';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import type { PairWithId } from './core/PairEditor';
import type { Pair, PairEditorHandle, PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
export function EnvironmentEditor({
environment,
hideName,
className,
}: {
interface Props {
environment: Environment;
hideName?: boolean;
className?: string;
}) {
addOrFocusVariable?: EnvironmentVariable;
}
export function EnvironmentEditor({ environment, hideName, className, addOrFocusVariable }: Props) {
const workspaceId = environment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
@@ -97,11 +96,54 @@ export function EnvironmentEditor({
});
};
const { pairs, autoFocusValue } = useMemo<{
pairs: Pair[];
autoFocusValue?: string;
}>(() => {
if (addOrFocusVariable != null) {
const existing = environment.variables.find(
(v) => v.id === addOrFocusVariable.id || v.name === addOrFocusVariable.name,
);
if (existing) {
return {
pairs: environment.variables,
autoFocusValue: existing.id,
};
} else {
const newPair = ensurePairId(addOrFocusVariable);
return {
pairs: [...environment.variables, newPair],
autoFocusValue: newPair.id,
};
}
} else {
return { pairs: environment.variables };
}
}, [addOrFocusVariable, environment.variables]);
const initPairEditor = useCallback(
(n: PairEditorHandle | null) => {
if (n && autoFocusValue) {
n.focusValue(autoFocusValue);
}
},
[autoFocusValue],
);
return (
<div className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3')}>
<div
className={classNames(
className,
'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3',
)}
>
<div className="flex flex-col gap-4">
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator className="mr-2" clickToEdit environment={environment ?? null} />
<EnvironmentColorIndicator
className="mr-2"
clickToEdit
environment={environment ?? null}
/>
{!hideName && <div className="mr-2">{environment?.name}</div>}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (
@@ -146,6 +188,7 @@ export function EnvironmentEditor({
)}
</div>
<PairOrBulkEditor
setRef={initPairEditor}
className="h-full"
allowMultilineValues
preferenceName="environment"
@@ -156,7 +199,7 @@ export function EnvironmentEditor({
valueAutocompleteVariables="environment"
valueAutocompleteFunctions
forceUpdateKey={`${environment.id}::${forceUpdateKey}`}
pairs={environment.variables}
pairs={pairs}
onChange={handleChange}
stateKey={`environment.${environment.id}`}
forcedEnvironmentId={environment.id}

View File

@@ -10,7 +10,7 @@ import {
stateExtensions,
updateSchema,
} from 'codemirror-json-schema';
import { useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog';
@@ -40,6 +40,9 @@ export function GrpcEditor({
...extraEditorProps
}: Props) {
const editorViewRef = useRef<EditorView>(null);
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
editorViewRef.current = h;
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
@@ -167,6 +170,7 @@ export function GrpcEditor({
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
setRef={handleInitEditorViewRef}
language="json"
autocompleteFunctions
autocompleteVariables
@@ -174,7 +178,6 @@ export function GrpcEditor({
defaultValue={request.message}
heightMode="auto"
placeholder="..."
ref={editorViewRef}
extraExtensions={extraExtensions}
actions={actions}
stateKey={`grpc_message.${request.id}`}

View File

@@ -53,20 +53,6 @@ export function Overlay({
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true, // So we can still click toasts and things
delayInitialFocus: true,
initialFocus: () =>
// Doing this explicitly seems to work better than the default behavior for some reason
containerRef.current?.querySelector<HTMLElement>(
[
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable]:not([contenteditable="false"])',
].join(', '),
) ?? false,
}}
>
<m.div

View File

@@ -77,6 +77,9 @@ function Sidebar({ className }: { className?: string }) {
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
const setFilterRef = useCallback((h: InputHandle | null) => {
filterRef.current = h;
}, []);
const allHidden = useMemo(() => {
if (tree?.children?.length === 0) return false;
else if (filterText) return tree?.children?.every((c) => c.hidden);
@@ -434,7 +437,7 @@ function Sidebar({ className }: { className?: string }) {
<>
<Input
hideLabel
ref={filterRef}
setRef={setFilterRef}
size="sm"
label="filter"
language={null} // Explicitly disable

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { FormEvent, ReactNode } from 'react';
import { memo, useRef, useState } from 'react';
import { useCallback, memo, useRef, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import type { IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
@@ -46,6 +46,10 @@ export const UrlBar = memo(function UrlBar({
const inputRef = useRef<InputHandle>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleInitInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
useHotKey('url_bar.focus', () => {
inputRef.current?.selectAll();
});
@@ -59,7 +63,7 @@ export const UrlBar = memo(function UrlBar({
return (
<form onSubmit={handleSubmit} className={classNames('x-theme-urlBar', className)}>
<Input
ref={inputRef}
setRef={handleInitInputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import type { PairEditorProps, PairEditorRef } from './core/PairEditor';
import type { PairEditorHandle, PairEditorProps } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
@@ -13,15 +13,19 @@ type Props = {
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditor = useRef<PairEditorRef>(null);
const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
return (pairEditorRef.current = ref);
}, []);
const [{ urlParametersKey }] = useRequestEditor();
useRequestEditorEvent(
'request_params.focus_value',
(name) => {
const pairIndex = pairs.findIndex((p) => p.name === name);
if (pairIndex >= 0) {
pairEditor.current?.focusValue(pairIndex);
const pair = pairs.find((p) => p.name === name);
if (pair?.id != null) {
pairEditorRef.current?.focusValue(pair.id);
} else {
console.log(`Couldn't find pair to focus`, { name, pairs });
}
@@ -32,7 +36,7 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
return (
<VStack className="h-full">
<PairOrBulkEditor
ref={pairEditor}
setRef={handleInitPairEditorRef}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions

View File

@@ -17,17 +17,16 @@ import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { ReactNode, RefObject } from 'react';
import {
useEffect,
Children,
cloneElement,
forwardRef,
isValidElement,
useCallback,
useImperativeHandle,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
import { activeWorkspaceAtom } from '../../../hooks/useActiveWorkspace';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
@@ -40,7 +39,6 @@ import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { jotaiStore } from '../../../lib/jotai';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { InlineCode } from '../InlineCode';
import { HStack } from '../Stacks';
@@ -97,6 +95,7 @@ export interface EditorProps {
tooltipContainer?: HTMLElement;
type?: 'text' | 'password';
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
const stateFields = { history: historyField, folds: foldState };
@@ -104,41 +103,39 @@ const stateFields = { history: historyField, folds: foldState };
const emptyVariables: WrappedEnvironmentVariable[] = [];
const emptyExtension: Extension = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
actions,
autoFocus,
autoSelect,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey: forceUpdateKeyFromAbove,
format,
heightMode,
hideGutter,
graphQLSchema,
language,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
stateKey,
type,
wrapLines,
}: EditorProps,
ref,
) {
export function Editor({
actions,
autoFocus,
autoSelect,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey: forceUpdateKeyFromAbove,
format,
heightMode,
hideGutter,
graphQLSchema,
language,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
stateKey,
type,
wrapLines,
setRef,
}: EditorProps) {
const settings = useAtomValue(settingsAtom);
const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);
@@ -182,7 +179,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view, []);
// Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange);
@@ -324,33 +320,15 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const onClickVariable = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {
editEnvironment(v.environment);
editEnvironment(v.environment, { addOrFocusVariable: v.variable });
},
[],
);
const onClickMissingVariable = useCallback(
async (_name: string, tagValue: string, startPos: number) => {
const initialTokens = parseTemplate(tagValue);
showDialog({
size: 'dynamic',
id: 'template-variable',
title: 'Configure Variable',
render: ({ hide }) => (
<TemplateVariableDialog
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[],
);
const onClickMissingVariable = useCallback(async (name: string) => {
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
editEnvironment(activeEnvironment, { addOrFocusVariable: { name, value: '', enabled: true } });
}, []);
const [, { focusParamValue }] = useRequestEditor();
const onClickPathParameter = useCallback(
@@ -469,6 +447,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
setRef?.(view);
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
@@ -588,7 +567,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
)}
</div>
);
});
}
function getExtensions({
stateKey,

View File

@@ -1,13 +1,12 @@
import type { EditorView } from '@codemirror/view';
import { forwardRef, lazy, Suspense } from 'react';
import { lazy, Suspense } from 'react';
import type { EditorProps } from './Editor';
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
export const Editor = forwardRef<EditorView, EditorProps>(function LazyEditor(props, ref) {
export function Editor(props: EditorProps) {
return (
<Suspense>
<Editor_ ref={ref} {...props} />
<Editor_ {...props} />
</Suspense>
);
});
}

View File

@@ -2,15 +2,7 @@ import type { EditorView } from '@codemirror/view';
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFastMutation } from '../../hooks/useFastMutation';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
@@ -80,6 +72,7 @@ export type InputProps = Pick<
type?: 'text' | 'password';
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
setRef?: (h: InputHandle | null) => void;
};
export interface InputHandle {
@@ -90,80 +83,86 @@ export interface InputHandle {
dispatch: EditorView['dispatch'];
}
export const Input = forwardRef<InputHandle, InputProps>(function Input({ type, ...props }, ref) {
export function Input({ type, ...props }: InputProps) {
// If it's a password and template functions are supported (ie. secure(...)) then
// use the encrypted input component.
if (type === 'password' && props.autocompleteFunctions) {
return <EncryptionInput {...props} />;
} else {
return <BaseInput ref={ref} type={type} {...props} />;
return <BaseInput type={type} {...props} />;
}
});
}
const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
{
className,
containerClassName,
defaultValue,
disableObscureToggle,
disabled,
forceUpdateKey,
fullHeight,
help,
hideLabel,
inputWrapperClassName,
label,
labelClassName,
labelPosition = 'top',
leftSlot,
multiLine,
onBlur,
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
required,
rightSlot,
size = 'md',
stateKey,
tint,
type = 'text',
validate,
wrapLines,
...props
}: InputProps,
ref,
) {
function BaseInput({
className,
containerClassName,
defaultValue,
disableObscureToggle,
disabled,
forceUpdateKey,
fullHeight,
help,
hideLabel,
inputWrapperClassName,
label,
labelClassName,
labelPosition = 'top',
leftSlot,
multiLine,
onBlur,
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
required,
rightSlot,
size = 'md',
stateKey,
tint,
type = 'text',
validate,
wrapLines,
setRef,
...props
}: InputProps) {
const [focused, setFocused] = useState(false);
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
const inputHandle = useMemo<InputHandle>(
() => ({
focus: () => {
editorRef.current?.focus();
},
isFocused: () => editorRef.current?.hasFocus ?? false,
value: () => editorRef.current?.state.doc.toString() ?? '',
dispatch: (...args) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editorRef.current?.dispatch(...(args as any));
},
selectAll() {
const head = editorRef.current?.state.doc.length ?? 0;
editorRef.current?.dispatch({
selection: { anchor: 0, head },
});
editorRef.current?.focus();
},
}),
[],
);
const initEditorRef = useCallback(
(cm: EditorView | null) => {
editorRef.current = cm;
if (cm == null) {
setRef?.(null);
return;
}
const handle: InputHandle = {
focus: () => {
cm.focus();
cm.dispatch({ selection: { anchor: cm.state.doc.length, head: cm.state.doc.length } });
},
isFocused: () => cm.hasFocus ?? false,
value: () => cm.state.doc.toString() ?? '',
dispatch: (...args) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cm.dispatch(...(args as any));
},
selectAll() {
cm.focus();
useImperativeHandle(ref, (): InputHandle => inputHandle, [inputHandle]);
cm.dispatch({
selection: { anchor: 0, head: cm.state.doc.length },
});
},
};
setRef?.(handle);
},
[setRef],
);
const lastWindowFocus = useRef<number>(0);
useEffect(() => {
@@ -300,7 +299,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
)}
>
<Editor
ref={editorRef}
setRef={initEditorRef}
id={id.current}
hideGutter
singleLine={!multiLine}
@@ -351,7 +350,7 @@ const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
</HStack>
</div>
);
});
}
function validateRequire(v: string) {
return v.length > 0;
@@ -365,8 +364,9 @@ function EncryptionInput({
autocompleteFunctions,
autocompleteVariables,
forceUpdateKey: ogForceUpdateKey,
setRef,
...props
}: Omit<InputProps, 'type'>) {
}: InputProps) {
const isEncryptionEnabled = useIsEncryptionEnabled();
const [state, setState] = useStateWithDeps<{
fieldType: PasswordFieldType;
@@ -374,11 +374,19 @@ function EncryptionInput({
security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean;
error: string | null;
}>({ fieldType: 'text', value: null, security: null, obscured: true, error: null }, [
ogForceUpdateKey,
]);
}>(
{
fieldType: isEncryptionEnabled ? 'encrypted' : 'text',
value: null,
security: null,
obscured: true,
error: null,
},
[ogForceUpdateKey],
);
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
const inputRef = useRef<InputHandle>(null);
useEffect(() => {
if (state.value != null) {
@@ -392,6 +400,9 @@ function EncryptionInput({
templateToInsecure.mutate(defaultValue ?? '', {
onSuccess: (value) => {
setState({ fieldType: 'encrypted', security, value, obscured: true, error: null });
// We're calling this here because we want the input to be fully initialized so the caller
// can do stuff like change the selection.
setRef?.(inputRef.current);
},
onError: (value) => {
setState({
@@ -406,6 +417,7 @@ function EncryptionInput({
} else if (isEncryptionEnabled && !defaultValue) {
// Default to encrypted field for new encrypted inputs
setState({ fieldType: 'encrypted', security, value: '', obscured: true, error: null });
setRef?.(inputRef.current);
} else if (isEncryptionEnabled) {
// Don't obscure plain text when encryption is enabled
setState({
@@ -424,8 +436,9 @@ function EncryptionInput({
obscured: true,
error: null,
});
setRef?.(inputRef.current);
}
}, [defaultValue, isEncryptionEnabled, setState, state.value]);
}, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);
const handleChange = useCallback(
(value: string, fieldType: PasswordFieldType) => {
@@ -454,6 +467,10 @@ function EncryptionInput({
[handleChange, state],
);
const handleSetInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
const handleFieldTypeChange = useCallback(
(newFieldType: PasswordFieldType) => {
const { value, fieldType } = state;
@@ -563,6 +580,7 @@ function EncryptionInput({
return (
<BaseInput
setRef={handleSetInputRef}
disableObscureToggle
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}

View File

@@ -10,16 +10,7 @@ import {
useSensors,
} from '@dnd-kit/core';
import classNames from 'classnames';
import {
forwardRef,
Fragment,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { WrappedEnvironmentVariable } from '../../hooks/useEnvironmentVariables';
import { useRandomKey } from '../../hooks/useRandomKey';
import { useToggle } from '../../hooks/useToggle';
@@ -45,8 +36,9 @@ import { PlainInput } from './PlainInput';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
export interface PairEditorRef {
focusValue(index: number): void;
export interface PairEditorHandle {
focusName(id: string): void;
focusValue(id: string): void;
}
export type PairEditorProps = {
@@ -64,6 +56,7 @@ export type PairEditorProps = {
onChange: (pairs: PairWithId[]) => void;
pairs: Pair[];
stateKey: InputProps['stateKey'];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean | 'environment';
@@ -89,33 +82,29 @@ export type PairWithId = Pair & {
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 50;
export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function PairEditor(
{
allowFileValues,
allowMultilineValues,
className,
forcedEnvironmentId,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
}: PairEditorProps,
ref,
) {
const [forceFocusNamePairId, setForceFocusNamePairId] = useState<string | null>(null);
const [forceFocusValuePairId, setForceFocusValuePairId] = useState<string | null>(null);
export function PairEditor({
allowFileValues,
allowMultilineValues,
className,
forcedEnvironmentId,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
const [pairs, setPairs] = useState<PairWithId[]>([]);
@@ -124,15 +113,30 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
useImperativeHandle(
ref,
const rowsRef = useRef<Record<string, RowHandle | null>>({});
const handle = useMemo<PairEditorHandle>(
() => ({
focusValue(index: number) {
const id = pairs[index]?.id ?? 'n/a';
setForceFocusValuePairId(id);
focusName(id: string) {
rowsRef.current[id]?.focusName();
},
focusValue(id: string) {
rowsRef.current[id]?.focusValue();
},
}),
[pairs],
[],
);
const initPairEditorRow = useCallback(
(id: string, n: RowHandle | null) => {
rowsRef.current[id] = n;
const ready =
Object.values(rowsRef.current).filter((v) => v != null).length === pairs.length - 1; // Ignore the last placeholder pair
if (ready) {
setRef?.(handle);
}
},
[handle, pairs.length, setRef],
);
useEffect(() => {
@@ -179,21 +183,19 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
setForceFocusNamePairId(id);
rowsRef.current[id ?? 'n/a']?.focusName();
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
[setPairsAndSave, setForceFocusNamePairId, pairs],
[setPairsAndSave, pairs],
);
const handleFocusName = useCallback((pair: Pair) => {
setForceFocusNamePairId(null); // Remove focus override when something focused
setForceFocusValuePairId(null); // Remove focus override when something focused
setPairs((pairs) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) {
const prevPair = pairs[pairs.length - 1];
setTimeout(() => setForceFocusNamePairId(prevPair?.id ?? null));
rowsRef.current[prevPair?.id ?? 'n/a']?.focusName();
return [...pairs, emptyPair()];
} else {
return pairs;
@@ -202,13 +204,11 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
}, []);
const handleFocusValue = useCallback((pair: Pair) => {
setForceFocusNamePairId(null); // Remove focus override when something focused
setForceFocusValuePairId(null); // Remove focus override when something focused
setPairs((pairs) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) {
const prevPair = pairs[pairs.length - 1];
setTimeout(() => setForceFocusValuePairId(prevPair?.id ?? null));
rowsRef.current[prevPair?.id ?? 'n/a']?.focusValue();
return [...pairs, emptyPair()];
} else {
return pairs;
@@ -301,12 +301,11 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<PairEditorRow
setRef={initPairEditorRow}
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId}
forceUpdateKey={localForceUpdateKey}
index={i}
isLast={isLast}
@@ -352,7 +351,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
</DndContext>
</div>
);
});
}
type PairEditorRowProps = {
className?: string;
@@ -369,6 +368,7 @@ type PairEditorRowProps = {
disableDrag?: boolean;
index: number;
isDraggingGlobal?: boolean;
setRef?: (id: string, n: RowHandle | null) => void;
} & Pick<
PairEditorProps,
| 'allowFileValues'
@@ -389,14 +389,17 @@ type PairEditorRowProps = {
| 'valueValidate'
>;
interface RowHandle {
focusName(): void;
focusValue(): void;
}
export function PairEditorRow({
allowFileValues,
allowMultilineValues,
className,
disableDrag,
disabled,
forceFocusNamePairId,
forceFocusValuePairId,
forceUpdateKey,
forcedEnvironmentId,
index,
@@ -419,21 +422,38 @@ export function PairEditorRow({
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorRowProps) {
const nameInputRef = useRef<InputHandle>(null);
const valueInputRef = useRef<InputHandle>(null);
useEffect(() => {
if (forceFocusNamePairId === pair.id) {
const handle = useRef<RowHandle>({
focusName() {
nameInputRef.current?.focus();
}
}, [forceFocusNamePairId, pair.id]);
useEffect(() => {
if (forceFocusValuePairId === pair.id) {
},
focusValue() {
valueInputRef.current?.focus();
}
}, [forceFocusValuePairId, pair.id]);
},
});
const initNameInputRef = useCallback(
(n: InputHandle | null) => {
nameInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const initValueInputRef = useCallback(
(n: InputHandle | null) => {
valueInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);
const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);
@@ -574,7 +594,7 @@ export function PairEditorRow({
/>
) : (
<Input
ref={nameInputRef}
setRef={initNameInputRef}
hideLabel
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
@@ -632,7 +652,7 @@ export function PairEditorRow({
</Button>
) : (
<Input
ref={valueInputRef}
setRef={initValueInputRef}
hideLabel
stateKey={`value.${pair.id}.${stateKey}`}
wrapLines={false}

View File

@@ -1,9 +1,8 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
import { useKeyValue } from '../../hooks/useKeyValue';
import { BulkPairEditor } from './BulkPairEditor';
import { IconButton } from './IconButton';
import type { PairEditorProps, PairEditorRef } from './PairEditor';
import type { PairEditorProps } from './PairEditor';
import { PairEditor } from './PairEditor';
interface Props extends PairEditorProps {
@@ -11,10 +10,7 @@ interface Props extends PairEditorProps {
forcedEnvironmentId?: string;
}
export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOrBulkEditor(
{ preferenceName, ...props }: Props,
ref,
) {
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
namespace: 'global',
key: ['bulk_edit', preferenceName],
@@ -23,7 +19,7 @@ export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOr
return (
<div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor ref={ref} {...props} />}
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
<div className="absolute right-0 bottom-0">
<IconButton
size="sm"
@@ -39,4 +35,4 @@ export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOr
</div>
</div>
);
});
}

View File

@@ -31,7 +31,7 @@ export type TreeItemProps<T extends { id: string }> = Pick<
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void;
setRef?: (item: T, n: TreeItemHandle | null) => void;
};
export interface TreeItemHandle {
@@ -54,7 +54,7 @@ function TreeItem_<T extends { id: string }>({
getEditOptions,
className,
depth,
addRef,
setRef,
}: TreeItemProps<T>) {
const listItemRef = useRef<HTMLLIElement>(null);
const draggableRef = useRef<HTMLButtonElement>(null);
@@ -86,8 +86,8 @@ function TreeItem_<T extends { id: string }>({
);
useEffect(() => {
addRef?.(node.item, handle);
}, [addRef, handle, node.item]);
setRef?.(node.item, handle);
}, [setRef, handle, node.item]);
const ancestorIds = useMemo(() => {
const ids: string[] = [];

View File

@@ -35,7 +35,7 @@ export function TreeItemList<T extends { id: string }>({
<Fragment key={getItemKey(child.node.item)}>
<TreeItem
treeId={treeId}
addRef={addTreeItemRef}
setRef={addTreeItemRef}
node={child.node}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}

View File

@@ -1,9 +1,8 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import { formatSdl } from 'format-graphql';
import { useAtom } from 'jotai';
import { useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useIntrospectGraphQL } from '../../hooks/useIntrospectGraphQL';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
@@ -26,7 +25,6 @@ type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> &
};
export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorProps }: Props) {
const editorViewRef = useRef<EditorView>(null);
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean>
>('graphQLAutoIntrospectDisabled', {});
@@ -199,7 +197,6 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
defaultValue={currentBody.query}
onChange={handleChangeQuery}
placeholder="..."
ref={editorViewRef}
actions={actions}
stateKey={'graphql_body.' + request.id}
{...extraEditorProps}

View File

@@ -4,14 +4,17 @@ import { jotaiStore } from './jotai';
export const dialogsAtom = atom<DialogInstance[]>([]);
export function showDialog({ id, ...props }: DialogInstance) {
jotaiStore.set(dialogsAtom, (a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
}
export function toggleDialog({ id, ...props }: DialogInstance) {
const dialogs = jotaiStore.get(dialogsAtom);
if (dialogs.some((d) => d.id === id)) hideDialog(id);
else showDialog({ id, ...props });
if (dialogs.some((d) => d.id === id)) {
hideDialog(id);
} else {
showDialog({ id, ...props });
}
}
export function showDialog({ id, ...props }: DialogInstance) {
jotaiStore.set(dialogsAtom, (a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
}
export function hideDialog(id: string) {

View File

@@ -1,9 +1,13 @@
import type { Environment } from '@yaakapp-internal/models';
import type { Environment, EnvironmentVariable } from '@yaakapp-internal/models';
import { openFolderSettings } from '../commands/openFolderSettings';
import { EnvironmentEditDialog } from '../components/EnvironmentEditDialog';
import { toggleDialog } from './dialog';
export function editEnvironment(environment: Environment | null) {
interface Options {
addOrFocusVariable?: EnvironmentVariable;
}
export function editEnvironment(environment: Environment | null, options: Options = {}) {
if (environment?.parentModel === 'folder' && environment.parentId != null) {
openFolderSettings(environment.parentId, 'variables');
} else {
@@ -12,7 +16,12 @@ export function editEnvironment(environment: Environment | null) {
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={environment} />,
render: () => (
<EnvironmentEditDialog
initialEnvironmentId={environment?.id ?? null}
addOrFocusVariable={options.addOrFocusVariable}
/>
),
});
}
}