Multipart form UI and fixes

This commit is contained in:
Gregory Schier
2023-11-14 00:32:02 -08:00
parent a4f5e4a6b8
commit f60cb35b5e
7 changed files with 113 additions and 45 deletions

View File

@@ -196,9 +196,9 @@ async fn send_request(
let pool2 = pool.clone(); let pool2 = pool.clone();
tokio::spawn(async move { tokio::spawn(async move {
actually_send_request(req, &response2, &environment_id2, &app_handle2, &pool2) if let Err(e) = actually_send_request(req, &response2, &environment_id2, &app_handle2, &pool2).await {
.await response_err(&response2, e, &app_handle2, &pool2).await.expect("Failed to update response");
.expect("Failed to send request"); }
}); });
emit_and_return(&window, "created_model", response) emit_and_return(&window, "created_model", response)

View File

@@ -160,7 +160,7 @@ pub async fn actually_send_request(
multipart_form = multipart_form.part( multipart_form = multipart_form.part(
render::render(name, &workspace, environment_ref), render::render(name, &workspace, environment_ref),
match !file.is_empty() { match !file.is_empty() {
true => multipart::Part::bytes(fs::read(file).expect("Failed to read file")), true => multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?),
false => multipart::Part::text(render::render(value, &workspace, environment_ref)), false => multipart::Part::text(render::render(value, &workspace, environment_ref)),
}, },
); );

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import type { PairEditorProps } from './core/PairEditor'; import type { Pair, PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor'; import { PairEditor } from './core/PairEditor';
type Props = { type Props = {
@@ -10,18 +10,27 @@ type Props = {
}; };
export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) { export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {
const pairs = useMemo( const pairs = useMemo<Pair[]>(
() => () =>
(Array.isArray(body.form) ? body.form : []).map((p) => ({ (Array.isArray(body.form) ? body.form : []).map((p) => ({
enabled: p.enabled, enabled: p.enabled,
name: p.name, name: p.name,
value: p.value, value: p.file ?? p.value,
isFile: !!p.file,
})), })),
[body.form], [body.form],
); );
const handleChange = useCallback<PairEditorProps['onChange']>( const handleChange = useCallback<PairEditorProps['onChange']>(
(pairs) => onChange({ form: pairs }), (pairs) =>
onChange({
form: pairs.map((p) => ({
enabled: p.enabled,
name: p.name,
file: p.isFile ? p.value : undefined,
value: p.isFile ? undefined : p.value,
})),
}),
[onChange], [onChange],
); );
@@ -29,6 +38,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {
<PairEditor <PairEditor
valueAutocompleteVariables valueAutocompleteVariables
nameAutocompleteVariables nameAutocompleteVariables
allowFileValues
pairs={pairs} pairs={pairs}
onChange={handleChange} onChange={handleChange}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import type { PairEditorProps } from './core/PairEditor'; import type { Pair, PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor'; import { PairEditor } from './core/PairEditor';
type Props = { type Props = {
@@ -10,18 +10,19 @@ type Props = {
}; };
export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props) { export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props) {
const pairs = useMemo( const pairs = useMemo<Pair[]>(
() => () =>
(Array.isArray(body.form) ? body.form : []).map((p) => ({ (Array.isArray(body.form) ? body.form : []).map((p) => ({
enabled: p.enabled, enabled: !!p.enabled,
name: p.name, name: p.name || '',
value: p.value, value: p.value || '',
})), })),
[body.form], [body.form],
); );
const handleChange = useCallback<PairEditorProps['onChange']>( const handleChange = useCallback<PairEditorProps['onChange']>(
(pairs) => onChange({ form: pairs }), (pairs) =>
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
[onChange], [onChange],
); );

View File

@@ -222,8 +222,9 @@ export function Sidebar({ className }: Props) {
useKey( useKey(
'ArrowUp', 'ArrowUp',
() => { (e) => {
if (!hasFocus) return; if (!hasFocus) return;
e.preventDefault();
const i = selectableRequests.findIndex((r) => r.id === selectedId); const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i - 1]; const newSelectable = selectableRequests[i - 1];
if (newSelectable == null) { if (newSelectable == null) {
@@ -239,8 +240,9 @@ export function Sidebar({ className }: Props) {
useKey( useKey(
'ArrowDown', 'ArrowDown',
() => { (e) => {
if (!hasFocus) return; if (!hasFocus) return;
e.preventDefault();
const i = selectableRequests.findIndex((r) => r.id === selectedId); const i = selectableRequests.findIndex((r) => r.id === selectedId);
const newSelectable = selectableRequests[i + 1]; const newSelectable = selectableRequests[i + 1];
if (newSelectable == null) { if (newSelectable == null) {

View File

@@ -1,16 +1,19 @@
import { open } from '@tauri-apps/api/dialog';
import classNames from 'classnames'; import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd'; import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { DropMarker } from '../DropMarker'; import { DropMarker } from '../DropMarker';
import { Button } from './Button';
import { Checkbox } from './Checkbox'; import { Checkbox } from './Checkbox';
import { Dropdown } from './Dropdown';
import type { GenericCompletionConfig } from './Editor/genericCompletion'; import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import type { InputProps } from './Input'; import type { InputProps } from './Input';
import { Input } from './Input'; import { Input } from './Input';
import type { EditorView } from 'codemirror';
export type PairEditorProps = { export type PairEditorProps = {
pairs: Pair[]; pairs: Pair[];
@@ -23,6 +26,7 @@ export type PairEditorProps = {
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
nameAutocompleteVariables?: boolean; nameAutocompleteVariables?: boolean;
valueAutocompleteVariables?: boolean; valueAutocompleteVariables?: boolean;
allowFileValues?: boolean;
nameValidate?: InputProps['validate']; nameValidate?: InputProps['validate'];
valueValidate?: InputProps['validate']; valueValidate?: InputProps['validate'];
}; };
@@ -32,6 +36,7 @@ export type Pair = {
enabled?: boolean; enabled?: boolean;
name: string; name: string;
value: string; value: string;
isFile?: boolean;
}; };
type PairContainer = { type PairContainer = {
@@ -52,6 +57,7 @@ export const PairEditor = memo(function PairEditor({
valueAutocompleteVariables, valueAutocompleteVariables,
valuePlaceholder, valuePlaceholder,
valueValidate, valueValidate,
allowFileValues,
}: PairEditorProps) { }: PairEditorProps) {
const [forceFocusPairId, setForceFocusPairId] = useState<string | null>(null); const [forceFocusPairId, setForceFocusPairId] = useState<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
@@ -167,6 +173,7 @@ export const PairEditor = memo(function PairEditor({
pairContainer={p} pairContainer={p}
className="py-1" className="py-1"
isLast={isLast} isLast={isLast}
allowFileValues={allowFileValues}
nameAutocompleteVariables={nameAutocompleteVariables} nameAutocompleteVariables={nameAutocompleteVariables}
valueAutocompleteVariables={valueAutocompleteVariables} valueAutocompleteVariables={valueAutocompleteVariables}
forceFocusPairId={forceFocusPairId} forceFocusPairId={forceFocusPairId}
@@ -177,7 +184,6 @@ export const PairEditor = memo(function PairEditor({
valuePlaceholder={isLast ? valuePlaceholder : ''} valuePlaceholder={isLast ? valuePlaceholder : ''}
nameValidate={nameValidate} nameValidate={nameValidate}
valueValidate={valueValidate} valueValidate={valueValidate}
showDelete={!isLast}
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onDelete={handleDelete} onDelete={handleDelete}
@@ -199,7 +205,6 @@ type FormRowProps = {
className?: string; className?: string;
pairContainer: PairContainer; pairContainer: PairContainer;
forceFocusPairId?: string | null; forceFocusPairId?: string | null;
showDelete?: boolean;
onMove: (id: string, side: 'above' | 'below') => void; onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void; onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void; onChange: (pair: PairContainer) => void;
@@ -218,17 +223,18 @@ type FormRowProps = {
| 'nameValidate' | 'nameValidate'
| 'valueValidate' | 'valueValidate'
| 'forceUpdateKey' | 'forceUpdateKey'
| 'allowFileValues'
>; >;
const FormRow = memo(function FormRow({ const FormRow = memo(function FormRow({
allowFileValues,
className, className,
forceFocusPairId, forceFocusPairId,
forceUpdateKey, forceUpdateKey,
isLast, isLast,
nameAutocomplete, nameAutocomplete,
namePlaceholder,
nameAutocompleteVariables, nameAutocompleteVariables,
valueAutocompleteVariables, namePlaceholder,
nameValidate, nameValidate,
onChange, onChange,
onDelete, onDelete,
@@ -236,8 +242,8 @@ const FormRow = memo(function FormRow({
onFocus, onFocus,
onMove, onMove,
pairContainer, pairContainer,
showDelete,
valueAutocomplete, valueAutocomplete,
valueAutocompleteVariables,
valuePlaceholder, valuePlaceholder,
valueValidate, valueValidate,
}: FormRowProps) { }: FormRowProps) {
@@ -261,8 +267,14 @@ const FormRow = memo(function FormRow({
[onChange, id, pairContainer.pair], [onChange, id, pairContainer.pair],
); );
const handleChangeValue = useMemo( const handleChangeValueText = useMemo(
() => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value } }), () => (value: string) =>
onChange({ id, pair: { ...pairContainer.pair, value, isFile: false } }),
[onChange, id, pairContainer.pair],
);
const handleChangeValueFile = useMemo(
() => (value: string) => onChange({ id, pair: { ...pairContainer.pair, value, isFile: true } }),
[onChange, id, pairContainer.pair], [onChange, id, pairContainer.pair],
); );
@@ -354,31 +366,66 @@ const FormRow = memo(function FormRow({
autocomplete={nameAutocomplete} autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables} autocompleteVariables={nameAutocompleteVariables}
/> />
<Input <div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
hideLabel {pairContainer.pair.isFile ? (
useTemplating <Button
size="sm" size="xs"
containerClassName={classNames(isLast && 'border-dashed')} color="gray"
validate={valueValidate} className="font-mono text-xs"
forceUpdateKey={forceUpdateKey} onClick={async (e) => {
defaultValue={pairContainer.pair.value} e.preventDefault();
label="Value" const file = await open({
name="value" title: 'Select file',
onChange={handleChangeValue} multiple: false,
onFocus={handleFocus} });
placeholder={valuePlaceholder ?? 'value'} handleChangeValueFile((Array.isArray(file) ? file[0] : file) ?? '');
autocomplete={valueAutocomplete?.(pairContainer.pair.name)} }}
autocompleteVariables={valueAutocompleteVariables} >
/> {getFileName(pairContainer.pair.value) || 'Select File'}
</Button>
) : (
<Input
hideLabel
useTemplating
size="sm"
containerClassName={classNames(isLast && 'border-dashed')}
validate={valueValidate}
forceUpdateKey={forceUpdateKey}
defaultValue={pairContainer.pair.value}
label="Value"
name="value"
onChange={handleChangeValueText}
onFocus={handleFocus}
placeholder={valuePlaceholder ?? 'value'}
autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
autocompleteVariables={valueAutocompleteVariables}
/>
)}
{allowFileValues && (
<Dropdown
items={[
{ key: 'text', label: 'Text', onSelect: () => handleChangeValueText('') },
{ key: 'file', label: 'File', onSelect: () => handleChangeValueFile('') },
]}
>
<IconButton
iconSize="sm"
size="xs"
icon={isLast ? 'empty' : 'chevronDown'}
title="Select form data type"
/>
</Dropdown>
)}
</div>
</div> </div>
<IconButton <IconButton
aria-hidden={!showDelete} aria-hidden={isLast}
disabled={!showDelete} disabled={isLast}
color="custom" color="custom"
icon={showDelete ? 'trash' : 'empty'} icon={!isLast ? 'trash' : 'empty'}
size="sm" size="sm"
title="Delete header" title="Delete header"
onClick={showDelete ? handleDelete : undefined} onClick={!isLast ? handleDelete : undefined}
className="ml-0.5 group-hover:!opacity-100 focus-visible:!opacity-100" className="ml-0.5 group-hover:!opacity-100 focus-visible:!opacity-100"
/> />
</div> </div>
@@ -387,6 +434,11 @@ const FormRow = memo(function FormRow({
const newPairContainer = (initialPair?: Pair): PairContainer => { const newPairContainer = (initialPair?: Pair): PairContainer => {
const id = initialPair?.id ?? uuid(); const id = initialPair?.id ?? uuid();
const pair = initialPair ?? { name: '', value: '', enabled: true }; const pair = initialPair ?? { name: '', value: '', enabled: true, isFile: false };
return { id, pair }; return { id, pair };
}; };
const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? '';
};

View File

@@ -3,11 +3,14 @@ import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models'; import type { HttpResponse } from '../lib/models';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useAlert } from './useAlert';
export function useSendAnyRequest() { export function useSendAnyRequest() {
const environmentId = useActiveEnvironmentId(); const environmentId = useActiveEnvironmentId();
const alert = useAlert();
return useMutation<HttpResponse, string, string | null>({ return useMutation<HttpResponse, string, string | null>({
mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }), mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }),
onSettled: () => trackEvent('http_request', 'send'), onSettled: () => trackEvent('http_request', 'send'),
onError: (err) => alert({ title: 'Export Failed', body: err }),
}); });
} }