Binary file uploads and missing workspace empty state

This commit is contained in:
Gregory Schier
2024-03-10 10:56:38 -07:00
parent ce9ccd34e7
commit efd7e7bf84
27 changed files with 214 additions and 47 deletions

View File

@@ -0,0 +1,82 @@
import { open } from '@tauri-apps/api/dialog';
import mime from 'mime';
import { useKeyValue } from '../hooks/useKeyValue';
import type { HttpRequest } from '../lib/models';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { HStack, VStack } from './core/Stacks';
type Props = {
requestId: string;
contentType: string | null;
body: HttpRequest['body'];
onChange: (body: HttpRequest['body']) => void;
onChangeContentType: (contentType: string | null) => void;
};
export function BinaryFileEditor({
contentType,
body,
onChange,
onChangeContentType,
requestId,
}: Props) {
const ignoreContentType = useKeyValue<boolean>({
namespace: 'global',
key: ['ignore_content_type', requestId],
fallback: false,
});
const handleClick = async () => {
await ignoreContentType.set(false);
const path = await open({
title: 'Select File',
multiple: false,
});
if (path) {
onChange({ filePath: path });
}
};
const filePath = typeof body.filePath === 'string' ? body.filePath : undefined;
const mimeType = mime.getType(filePath ?? '') ?? 'application/octet-stream';
console.log('mimeType', mimeType, contentType);
return (
<VStack space={2}>
<HStack space={2} alignItems="center">
<Button variant="border" color="gray" size="sm" onClick={handleClick}>
Choose File
</Button>
<div className="text-xs font-mono truncate rtl pr-3 text-gray-800">
{/* Special character to insert ltr text in rtl element without making things wonky */}
&#x200E;
{filePath ?? 'Select File'}
</div>
</HStack>
{mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 !py-5">
<div className="text-sm mb-4 text-center">
<div>Set Content-Type header to</div>
<InlineCode>{mimeType}</InlineCode>?
</div>
<HStack space={1.5} justifyContent="center">
<Button
variant="solid"
color="gray"
size="xs"
onClick={() => onChangeContentType(mimeType)}
>
Set Header
</Button>
<Button size="xs" variant="border" onClick={() => ignoreContentType.set(true)}>
Ignore
</Button>
</HStack>
</Banner>
)}
</VStack>
);
}

View File

@@ -6,7 +6,7 @@ import { PairEditor } from './core/PairEditor';
type Props = {
forceUpdateKey: string;
body: HttpRequest['body'];
onChange: (headers: HttpRequest['body']) => void;
onChange: (body: HttpRequest['body']) => void;
};
export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {

View File

@@ -18,7 +18,6 @@ import { settingsQueryKey } from '../hooks/useSettings';
import { useSyncAppearance } from '../hooks/useSyncAppearance';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname';
@@ -142,7 +141,7 @@ function removeById<T extends { id: string }>(model: T) {
const shouldIgnoreModel = (payload: Model) => {
if (payload.model === 'key_value') {
return payload.namespace === NAMESPACE_NO_SYNC;
return payload.namespace === 'no_sync';
}
return false;
};

View File

@@ -6,24 +6,27 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models';
import {
BODY_TYPE_OTHER,
AUTH_TYPE_BASIC,
AUTH_TYPE_BEARER,
AUTH_TYPE_NONE,
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
} from '../lib/models';
import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth';
import { BinaryFileEditor } from './BinaryFileEditor';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { TabItem } from './core/Tabs/Tabs';
@@ -56,6 +59,7 @@ export const RequestPane = memo(function RequestPane({
const [activeTab, setActiveTab] = useActiveTab();
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const contentType = useContentTypeFromHeaders(activeRequest.headers);
const tabs: TabItem[] = useMemo(
() => [
@@ -68,11 +72,12 @@ export const RequestPane = memo(function RequestPane({
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED },
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART },
{ type: 'separator', label: 'Text Content' },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
{ label: 'Other', value: BODY_TYPE_OTHER },
{ type: 'separator', label: 'Other' },
{ label: 'Binary File', value: BODY_TYPE_BINARY },
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
],
onChange: async (bodyType) => {
@@ -94,7 +99,7 @@ export const RequestPane = memo(function RequestPane({
[]),
{
name: 'Content-Type',
value: bodyType,
value: bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType,
enabled: true,
},
];
@@ -111,10 +116,10 @@ export const RequestPane = memo(function RequestPane({
];
}
await updateRequest.mutateAsync(patch);
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
updateRequest.mutate(patch);
},
},
},
@@ -171,6 +176,27 @@ export const RequestPane = memo(function RequestPane({
(body: HttpRequest['body']) => updateRequest.mutate({ body }),
[updateRequest],
);
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {
const headers =
contentType != null
? activeRequest.headers.map((h) =>
h.name.toLowerCase() === 'content-type' ? { ...h, value: contentType } : h,
)
: activeRequest.headers;
await updateRequest.mutateAsync({ headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest.headers, updateRequest],
);
const handleBinaryFileChange = useCallback(
(body: HttpRequest['body']) => {
updateRequest.mutate({ body });
},
[updateRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => updateRequest.mutate({ body: { text } }),
[updateRequest],
@@ -314,6 +340,14 @@ export const RequestPane = memo(function RequestPane({
body={activeRequest.body}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={handleBinaryFileChange}
onChangeContentType={handleContentTypeChange}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}

View File

@@ -3,7 +3,7 @@ import type { CSSProperties } from 'react';
import { memo, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseContentType } from '../hooks/useResponseContentType';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models';
@@ -37,7 +37,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
const { activeResponse, setPinnedResponse, responses } = usePinnedHttpResponse(activeRequest);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [activeTab, setActiveTab] = useActiveTab();
const contentType = useResponseContentType(activeResponse);
const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
const tabs = useMemo<TabItem[]>(
() => [

View File

@@ -31,7 +31,6 @@ import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import type { DropdownItem } from './core/Dropdown';
@@ -87,7 +86,7 @@ export function Sidebar({ className }: Props) {
const collapsed = useKeyValue<Record<string, boolean>>({
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
fallback: {},
namespace: NAMESPACE_NO_SYNC,
namespace: 'no_sync',
});
useHotKey('http_request.duplicate', async () => {

View File

@@ -9,13 +9,19 @@ import type {
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useImportData } from '../hooks/useImportData';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { useOsInfo } from '../hooks/useOsInfo';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList';
import { InlineCode } from './core/InlineCode';
import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks';
import { CreateDropdown } from './CreateDropdown';
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
@@ -34,6 +40,9 @@ const drag = { gridArea: 'drag' };
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
export default function Workspace() {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = useActiveWorkspaceId();
const { setWidth, width, resetWidth } = useSidebarWidth();
const { hide, show, hidden } = useSidebarHidden();
const activeRequest = useActiveRequest();
@@ -119,6 +128,11 @@ export default function Workspace() {
);
}
// We're loading still
if (workspaces.length === 0) {
return null;
}
return (
<div
style={styles}
@@ -163,7 +177,15 @@ export default function Workspace() {
<HeaderSize data-tauri-drag-region style={head}>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{activeRequest == null ? (
{activeWorkspace == null ? (
<div className="m-auto">
<Banner color="warning" className="max-w-[30rem]">
The active workspace{' '}
<InlineCode className="text-orange-800">{activeWorkspaceId}</InlineCode> was not found.
Select a workspace from the header menu or report this bug to <FeedbackLink />
</Banner>
</div>
) : activeRequest == null ? (
<HotKeyList
hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']}
bottomSlot={

View File

@@ -167,10 +167,14 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
<Dropdown items={items}>
<Button
size="sm"
className={classNames(className, 'text-gray-800 !px-2 truncate')}
className={classNames(
className,
'text-gray-800 !px-2 truncate',
activeWorkspace === null && 'italic opacity-disabled',
)}
{...buttonProps}
>
{activeWorkspace?.name}
{activeWorkspace?.name ?? 'Workspace'}
</Button>
</Dropdown>
);

View File

@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
color?: 'danger' | 'success' | 'gray';
color?: 'danger' | 'warning' | 'success' | 'gray';
}
export function Banner({ children, className, color = 'gray' }: Props) {
return (
@@ -14,6 +14,7 @@ export function Banner({ children, className, color = 'gray' }: Props) {
className,
'border border-dashed italic px-3 py-2 rounded select-auto cursor-text',
color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800',
color === 'warning' && 'border-orange-500/60 bg-orange-300/10 text-orange-800',
color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800',
color === 'success' && 'border-green-500/60 bg-green-300/10 text-green-800',
)}

View File

@@ -33,3 +33,7 @@ export function Link({ href, children, className, ...other }: Props) {
</RouterLink>
);
}
export function FeedbackLink() {
return <Link href="https://yaak.canny.io">Feedback</Link>;
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useMemo } from 'react';
import { useDebouncedState } from '../../hooks/useDebouncedState';
import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useResponseContentType } from '../../hooks/useResponseContentType';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useToggle } from '../../hooks/useToggle';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models';
@@ -21,7 +21,7 @@ export function TextViewer({ response, pretty }: Props) {
const [isSearching, toggleIsSearching] = useToggle();
const [filterText, setDebouncedFilterText, setFilterText] = useDebouncedState<string>('', 400);
const contentType = useResponseContentType(response);
const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response) ?? '';
const formattedBody =
pretty && contentType?.includes('json')