Request pane context (#69)

This commit is contained in:
Gregory Schier
2024-09-02 14:36:55 -07:00
committed by GitHub
parent 3e8c556999
commit 0b9483954d
12 changed files with 200 additions and 76 deletions

4
package-lock.json generated
View File

@@ -34,6 +34,7 @@
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.6.1",
"date-fns": "^3.3.1",
"eventemitter3": "^5.0.1",
"fast-fuzzy": "^1.12.0",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
@@ -5750,8 +5751,7 @@
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"node_modules/execa": {
"version": "7.2.0",

View File

@@ -49,6 +49,7 @@
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.6.1",
"date-fns": "^3.3.1",
"eventemitter3": "^5.0.1",
"fast-fuzzy": "^1.12.0",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",

View File

@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
import { useOsInfo } from '../hooks/useOsInfo';
import { DialogProvider, Dialogs } from './DialogContext';
import { GlobalHooks } from './GlobalHooks';
import { RequestEditorProvider } from './RequestEditorContext';
import { ToastProvider, Toasts } from './ToastContext';
export function DefaultLayout() {
@@ -11,23 +12,25 @@ export function DefaultLayout() {
return (
<DialogProvider>
<ToastProvider>
<>
{/* Must be inside all the providers, so they have access to them */}
<Toasts />
<Dialogs />
</>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1, delay: 0.1 }}
className={classNames(
'w-full h-full',
osInfo?.osType === 'linux' && 'border border-border-subtle',
)}
>
<Outlet />
</motion.div>
<GlobalHooks />
<RequestEditorProvider>
<>
{/* Must be inside all the providers, so they have access to them */}
<Toasts />
<Dialogs />
</>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1, delay: 0.1 }}
className={classNames(
'w-full h-full',
osInfo?.osType === 'linux' && 'border border-border-subtle',
)}
>
<Outlet />
</motion.div>
<GlobalHooks />
</RequestEditorProvider>
</ToastProvider>
</DialogProvider>
);

View File

@@ -0,0 +1,58 @@
import EventEmitter from 'eventemitter3';
import type { DependencyList } from 'react';
import React, { createContext, useCallback, useContext, useEffect } from 'react';
interface State {
focusParamValue: (name: string) => void;
focusParamsTab: () => void;
}
export const RequestEditorContext = createContext<State>({} as State);
const emitter = new EventEmitter();
export const RequestEditorProvider = ({ children }: { children: React.ReactNode }) => {
const focusParamsTab = useCallback(() => {
emitter.emit('focus_http_request_params_tab');
}, []);
const focusParamValue = useCallback(
(name: string) => {
focusParamsTab();
setTimeout(() => {
emitter.emit('focus_http_request_param_value', name);
}, 50);
},
[focusParamsTab],
);
const state: State = {
focusParamValue,
focusParamsTab,
};
return <RequestEditorContext.Provider value={state}>{children}</RequestEditorContext.Provider>;
};
export function useOnFocusParamValue(cb: (name: string) => void, deps: DependencyList) {
useEffect(() => {
emitter.on('focus_http_request_param_value', cb);
return () => {
emitter.off('focus_http_request_param_value', cb);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
export function useOnFocusParamsTab(cb: () => void) {
useEffect(() => {
emitter.on('focus_http_request_params_tab', cb);
return () => {
emitter.off('focus_http_request_params_tab', cb);
};
// Only add callback once, to prevent the need for the caller to useCallback
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
export const useRequestEditor = () => useContext(RequestEditorContext);

View File

@@ -42,6 +42,7 @@ import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
import { GraphQLEditor } from './GraphQLEditor';
import { HeadersEditor } from './HeadersEditor';
import { useOnFocusParamsTab } from './RequestEditorContext';
import { useToast } from './ToastContext';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
@@ -54,6 +55,10 @@ interface Props {
}
const useActiveTab = createGlobalState<string>('body');
const TAB_BODY = 'body';
const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth';
export const RequestPane = memo(function RequestPane({
style,
@@ -94,7 +99,8 @@ export const RequestPane = memo(function RequestPane({
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '',
);
const items: Pair[] = [...activeRequest.urlParameters];
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const index = items.findIndex((p) => p.name === name);
if (index >= 0) {
@@ -114,7 +120,7 @@ export const RequestPane = memo(function RequestPane({
const tabs: TabItem[] = useMemo(
() => [
{
value: 'body',
value: TAB_BODY,
options: {
value: activeRequest.bodyType,
items: [
@@ -180,16 +186,16 @@ export const RequestPane = memo(function RequestPane({
},
},
{
value: 'params',
value: TAB_PARAMS,
label: (
<div className="flex items-center">
Params
<CountBadge count={urlParameterPairs.filter((p) => p.name).length} />
<CountBadge count={urlParameterPairs.length} />
</div>
),
},
{
value: 'headers',
value: TAB_HEADERS,
label: (
<div className="flex items-center">
Headers
@@ -198,7 +204,7 @@ export const RequestPane = memo(function RequestPane({
),
},
{
value: 'auth',
value: TAB_AUTH,
label: 'Auth',
options: {
value: activeRequest.authenticationType,
@@ -292,6 +298,10 @@ export const RequestPane = memo(function RequestPane({
const { updateKey } = useRequestUpdateKey(activeRequestId ?? null);
const importCurl = useImportCurl();
useOnFocusParamsTab(() => {
setActiveTab(TAB_PARAMS);
});
return (
<div
style={style}

View File

@@ -1,6 +1,9 @@
import type { HttpRequest } from '@yaakapp/api';
import type { PairEditorRef } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
import { useOnFocusParamValue } from './RequestEditorContext';
import { useRef } from 'react';
type Props = {
forceUpdateKey: string;
@@ -9,9 +12,24 @@ type Props = {
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange }: Props) {
const pairEditor = useRef<PairEditorRef>(null);
useOnFocusParamValue(
(name) => {
const pairIndex = pairs.findIndex((p) => p.name === name);
if (pairIndex >= 0) {
pairEditor.current?.focusValue(pairIndex);
} else {
console.log("Couldn't find pair to focus", { name, pairs });
}
},
[pairs],
);
return (
<VStack className="h-full">
<PairOrBulkEditor
ref={pairEditor}
preferenceName="url_parameters"
valueAutocompleteVariables
nameAutocompleteVariables

View File

@@ -22,6 +22,7 @@ import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useSettings } from '../../../hooks/useSettings';
import { useTemplateFunctions } from '../../../hooks/useTemplateFunctions';
import { useDialog } from '../../DialogContext';
import { useRequestEditor } from '../../RequestEditorContext';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
@@ -227,9 +228,13 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[dialog],
);
const onClickPathParameter = useCallback(async (name: string) => {
console.log('TODO: Focus', name, 'in params tab');
}, []);
const { focusParamValue } = useRequestEditor();
const onClickPathParameter = useCallback(
async (name: string) => {
focusParamValue(name);
},
[focusParamValue],
);
// Update the language extension when the language changes
useEffect(() => {

View File

@@ -120,7 +120,6 @@ function templateTags(
const onClick = () => onClickPathParameter(rawText);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
const deco = Decoration.replace({ widget, inclusive: false });
console.log('ADDED WIDGET', globalFrom, node, rawText);
widgets.push(deco.range(globalFrom, globalTo));
},
});
@@ -201,13 +200,6 @@ export function templateTagsPlugin(
return view.plugin(plugin)?.decorations || Decoration.none;
});
},
eventHandlers: {
mousedown(e) {
const target = e.target as HTMLElement;
if (target.classList.contains('template-tag')) console.log('CLICKED TEMPLATE TAG');
// return toggleBoolean(view, view.posAtDOM(target));
},
},
},
);
}

View File

@@ -3,9 +3,9 @@ import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
Protocol: t.comment,
Placeholder: t.emphasis,
PathSegment: t.tagName,
Port: t.attributeName,
Host: t.variableName,
Path: t.bool,
Query: t.string,
// PathSegment: t.tagName,
// Port: t.attributeName,
// Host: t.variableName,
// Path: t.bool,
// Query: t.string,
});

View File

@@ -1,6 +1,15 @@
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid';
@@ -16,6 +25,10 @@ import type { InputProps } from './Input';
import { Input } from './Input';
import { RadioDropdown } from './RadioDropdown';
export interface PairEditorRef {
focusValue(index: number): void;
}
export type PairEditorProps = {
pairs: Pair[];
onChange: (pairs: Pair[]) => void;
@@ -49,24 +62,28 @@ type PairContainer = {
id: string;
};
export function PairEditor({
className,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
valueType,
onChange,
noScroll,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
allowFileValues,
}: PairEditorProps) {
const [forceFocusPairId, setForceFocusPairId] = useState<string | null>(null);
export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function PairEditor(
{
className,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
valueType,
onChange,
noScroll,
pairs: originalPairs,
valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder,
valueValidate,
allowFileValues,
}: PairEditorProps,
ref,
) {
const [forceFocusNamePairId, setForceFocusNamePairId] = useState<string | null>(null);
const [forceFocusValuePairId, setForceFocusValuePairId] = useState<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<PairContainer[]>(() => {
// Remove empty headers on initial render
@@ -75,6 +92,13 @@ export function PairEditor({
return [...pairs, newPairContainer()];
});
useImperativeHandle(ref, () => ({
focusValue(index: number) {
const id = pairs[index]?.id ?? 'n/a';
setForceFocusValuePairId(id);
},
}));
useEffect(() => {
// Remove empty headers on initial render
// TODO: Make this not refresh the entire editor when forceUpdateKey changes, using some
@@ -135,17 +159,18 @@ export function PairEditor({
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
setForceFocusPairId(id);
setForceFocusNamePairId(id);
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
[setPairsAndSave, setForceFocusPairId, pairs],
[setPairsAndSave, setForceFocusNamePairId, pairs],
);
const handleFocus = useCallback(
(pair: PairContainer) =>
setPairs((pairs) => {
setForceFocusPairId(null); // Remove focus override when something focused
setForceFocusNamePairId(null); // Remove focus override when something focused
setForceFocusValuePairId(null); // Remove focus override when something focused
const isLast = pair.id === pairs[pairs.length - 1]?.id;
return isLast ? [...pairs, newPairContainer()] : pairs;
}),
@@ -185,7 +210,8 @@ export function PairEditor({
nameAutocompleteVariables={nameAutocompleteVariables}
valueAutocompleteVariables={valueAutocompleteVariables}
valueType={valueType}
forceFocusPairId={forceFocusPairId}
forceFocusNamePairId={forceFocusNamePairId}
forceFocusValuePairId={forceFocusValuePairId}
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
@@ -204,7 +230,7 @@ export function PairEditor({
})}
</div>
);
}
});
enum ItemTypes {
ROW = 'pair-row',
@@ -213,7 +239,8 @@ enum ItemTypes {
type PairEditorRowProps = {
className?: string;
pairContainer: PairContainer;
forceFocusPairId?: string | null;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void;
@@ -239,7 +266,8 @@ type PairEditorRowProps = {
function PairEditorRow({
allowFileValues,
className,
forceFocusPairId,
forceFocusNamePairId,
forceFocusValuePairId,
forceUpdateKey,
isLast,
nameAutocomplete,
@@ -262,12 +290,19 @@ function PairEditorRow({
const ref = useRef<HTMLDivElement>(null);
const prompt = usePrompt();
const nameInputRef = useRef<EditorView>(null);
const valueInputRef = useRef<EditorView>(null);
useEffect(() => {
if (forceFocusPairId === pairContainer.id) {
if (forceFocusNamePairId === pairContainer.id) {
nameInputRef.current?.focus();
}
}, [forceFocusPairId, pairContainer.id]);
}, [forceFocusNamePairId, pairContainer.id]);
useEffect(() => {
if (forceFocusValuePairId === pairContainer.id) {
valueInputRef.current?.focus();
}
}, [forceFocusValuePairId, pairContainer.id]);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange({ id, pair: { ...pairContainer.pair, enabled } }),
@@ -400,6 +435,7 @@ function PairEditorRow({
/>
) : (
<Input
ref={valueInputRef}
hideLabel
useTemplating
size="sm"

View File

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

View File

@@ -1,5 +1,4 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { attachConsole } from '@tauri-apps/plugin-log';
import { type } from '@tauri-apps/plugin-os';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
@@ -18,8 +17,6 @@ if (osType !== 'macos') {
await getCurrentWebviewWindow().setDecorations(false);
}
await attachConsole();
window.addEventListener('keydown', (e) => {
// Hack to not go back in history on backspace. Check for document body
// or else it will prevent backspace in input fields.