diff --git a/src-tauri/src/analytics.rs b/src-tauri/src/analytics.rs index def675d9..f44504ef 100644 --- a/src-tauri/src/analytics.rs +++ b/src-tauri/src/analytics.rs @@ -182,7 +182,7 @@ pub async fn track_event( // Disable analytics actual sending in dev if is_dev() { - debug!("track: {} {} {:?}", event, attributes_json, params); + // debug!("track: {} {} {:?}", event, attributes_json, params); return; } diff --git a/src-tauri/src/http.rs b/src-tauri/src/http.rs index 9c354d11..78672af6 100644 --- a/src-tauri/src/http.rs +++ b/src-tauri/src/http.rs @@ -259,7 +259,6 @@ pub async fn send_http_request( return response_err(response, e, window).await; } } - } else if body_type == "multipart/form-data" && request_body.contains_key("form") { let mut multipart_form = multipart::Form::new(); if let Some(form_definition) = request_body.get("form") { @@ -269,12 +268,13 @@ pub async fn send_http_request( .unwrap_or(empty_bool) .as_bool() .unwrap_or(false); - let name = p + let name_raw = p .get("name") .unwrap_or(empty_string) .as_str() .unwrap_or_default(); - if !enabled || name.is_empty() { + + if !enabled || name_raw.is_empty() { continue; } @@ -283,24 +283,41 @@ pub async fn send_http_request( .unwrap_or(empty_string) .as_str() .unwrap_or_default(); - let value = p + let value_raw = p .get("value") .unwrap_or(empty_string) .as_str() .unwrap_or_default(); - multipart_form = multipart_form.part( - render::render(name, &workspace, environment_ref), - match !file.is_empty() { - true => { - multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?) + + let name = render::render(name_raw, &workspace, environment_ref); + let part = if file.is_empty() { + multipart::Part::text(render::render( + value_raw, + &workspace, + environment_ref, + )) + } else { + match fs::read(file) { + Ok(f) => multipart::Part::bytes(f), + Err(e) => { + return response_err(response, e.to_string(), window).await; } - false => multipart::Part::text(render::render( - value, - &workspace, - environment_ref, - )), - }, - ); + } + }; + + let ct_raw = p + .get("contentType") + .unwrap_or(empty_string) + .as_str() + .unwrap_or_default(); + + multipart_form = multipart_form.part(name, if ct_raw.is_empty() { + part + } else { + let ct = render::render(ct_raw, &workspace, environment_ref); + println!("CT: {}", ct); + part.mime_str(ct.as_str()).map_err(|e| e.to_string())? + }); } } headers.remove("Content-Type"); // reqwest will add this automatically diff --git a/src-web/components/FormMultipartEditor.tsx b/src-web/components/FormMultipartEditor.tsx index 430529cf..0ef51048 100644 --- a/src-web/components/FormMultipartEditor.tsx +++ b/src-web/components/FormMultipartEditor.tsx @@ -16,6 +16,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) { enabled: p.enabled, name: p.name, value: p.file ?? p.value, + contentType: p.contentType, isFile: !!p.file, })), [body.form], @@ -27,6 +28,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) { form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, + contentType: p.contentType, file: p.isFile ? p.value : undefined, value: p.isFile ? undefined : p.value, })), diff --git a/src-web/components/GrpcConnectionSetupPane.tsx b/src-web/components/GrpcConnectionSetupPane.tsx index 83be560c..b607827a 100644 --- a/src-web/components/GrpcConnectionSetupPane.tsx +++ b/src-web/components/GrpcConnectionSetupPane.tsx @@ -195,7 +195,6 @@ export function GrpcConnectionSetupPane({ shortLabel: o.label, }))} extraItems={[ - { type: 'separator' }, { label: 'Refresh', type: 'default', diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index e3a3f33a..4046336c 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -5,6 +5,7 @@ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } fro import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { v4 as uuid } from 'uuid'; +import { usePrompt } from '../../hooks/usePrompt'; import { DropMarker } from '../DropMarker'; import { Button } from './Button'; import { Checkbox } from './Checkbox'; @@ -14,6 +15,7 @@ import { Icon } from './Icon'; import { IconButton } from './IconButton'; import type { InputProps } from './Input'; import { Input } from './Input'; +import { RadioDropdown } from './RadioDropdown'; export type PairEditorProps = { pairs: Pair[]; @@ -37,6 +39,7 @@ export type Pair = { enabled?: boolean; name: string; value: string; + contentType?: string; isFile?: boolean; }; @@ -254,6 +257,7 @@ const FormRow = memo(function FormRow({ }: FormRowProps) { const { id } = pairContainer; const ref = useRef(null); + const prompt = usePrompt(); const nameInputRef = useRef(null); useEffect(() => { @@ -283,6 +287,11 @@ const FormRow = memo(function FormRow({ [onChange, id, pairContainer.pair], ); + const handleChangeValueContentType = useMemo( + () => (contentType: string) => onChange({ id, pair: { ...pairContainer.pair, contentType } }), + [onChange, id, pairContainer.pair], + ); + const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]); const handleDelete = useCallback( () => onDelete?.(pairContainer, false), @@ -408,34 +417,67 @@ const FormRow = memo(function FormRow({ autocompleteVariables={valueAutocompleteVariables} /> )} - {allowFileValues && ( - handleChangeValueText('') }, - { key: 'file', label: 'File', onSelect: () => handleChangeValueFile('') }, - ]} - > - - - )} - + {allowFileValues ? ( + { + if (v === 'file') handleChangeValueFile(''); + else handleChangeValueText(''); + }} + items={[ + { label: 'Text', value: 'text' }, + { label: 'File', value: 'file' }, + ]} + extraItems={[ + { + key: 'mime', + label: 'Set Content-Type', + leftSlot: , + onSelect: async () => { + const v = await prompt({ + id: 'content-type', + require: false, + title: 'Override Content-Type', + label: 'Content-Type', + placeholder: 'text/plain', + defaultValue: pairContainer.pair.contentType ?? '', + name: 'content-type', + confirmLabel: 'Set', + description: 'Leave blank to auto-detect', + }); + handleChangeValueContentType(v); + }, + }, + { + key: 'delete', + label: 'Delete', + onSelect: handleDelete, + variant: 'danger', + leftSlot: , + }, + ]} + > + + + ) : ( + + + + )} ); }); diff --git a/src-web/components/core/RadioDropdown.tsx b/src-web/components/core/RadioDropdown.tsx index 7454da80..155ade41 100644 --- a/src-web/components/core/RadioDropdown.tsx +++ b/src-web/components/core/RadioDropdown.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import type { DropdownItemSeparator, DropdownProps } from './Dropdown'; +import type { DropdownItem, DropdownItemSeparator, DropdownProps } from './Dropdown'; import { Dropdown } from './Dropdown'; import { Icon } from './Icon'; @@ -42,7 +42,7 @@ export function RadioDropdown({ }; } }), - ...(extraItems ?? []), + ...((extraItems ? [{ type: 'separator' }, ...extraItems] : []) as DropdownItem[]), ], [items, extraItems, value, onChange], ); diff --git a/src-web/hooks/Prompt.tsx b/src-web/hooks/Prompt.tsx index 3c9f1b22..fa71a4fe 100644 --- a/src-web/hooks/Prompt.tsx +++ b/src-web/hooks/Prompt.tsx @@ -12,6 +12,7 @@ export interface PromptProps { name: InputProps['name']; defaultValue: InputProps['defaultValue']; placeholder: InputProps['placeholder']; + require?: InputProps['require']; confirmLabel?: string; } @@ -22,6 +23,7 @@ export function Prompt({ defaultValue, placeholder, onResult, + require = true, confirmLabel = 'Save', }: PromptProps) { const [value, setValue] = useState(defaultValue ?? ''); @@ -41,7 +43,7 @@ export function Prompt({ > & Omit & { id: string }) => new Promise((onResult: PromptProps['onResult']) => { @@ -24,7 +25,16 @@ export function usePrompt() { hideX: true, size: 'sm', render: ({ hide }) => - Prompt({ onHide: hide, onResult, name, label, defaultValue, placeholder, confirmLabel }), + Prompt({ + onHide: hide, + onResult, + name, + label, + defaultValue, + placeholder, + confirmLabel, + require, + }), }); }); }