Optimized a few components

This commit is contained in:
Gregory Schier
2023-03-18 18:49:01 -07:00
parent 6fc9b5a185
commit b1835561a8
17 changed files with 200 additions and 156 deletions

View File

@@ -1,13 +1,15 @@
import classnames from 'classnames';
import { act } from 'react-dom/test-utils';
import { useCallback, useMemo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useKeyValue } from '../hooks/useKeyValue';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader } from '../lib/models';
import { Editor } from './core/Editor';
import { PairEditor } from './core/PairEditor';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { GraphQLEditor } from './editors/GraphQLEditor';
import { UrlBar } from './UrlBar';
@@ -19,14 +21,45 @@ interface Props {
export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const updateRequest = useUpdateRequest(activeRequest);
const sendRequest = useSendRequest(activeRequest);
const activeRequestId = activeRequest?.id ?? null;
const updateRequest = useUpdateRequest(activeRequestId);
const sendRequest = useSendRequest(activeRequestId);
const responseLoading = useIsResponseLoading();
const activeTab = useKeyValue<string>({
key: ['active_request_body_tab', activeRequest?.id ?? 'n/a'],
key: ['active_request_body_tab', activeRequestId ?? 'n/a'],
initialValue: 'body',
});
const tabs: TabItem[] = useMemo(
() => [
{
value: 'body',
label: activeRequest?.bodyType ?? 'NoBody',
options: {
onValueChange: (t) => updateRequest.mutate({ bodyType: t.value }),
value: activeRequest?.bodyType ?? 'nobody',
items: [
{ label: 'No Body', value: 'nobody' },
{ label: 'JSON', value: 'json' },
{ label: 'GraphQL', value: 'graphql' },
],
},
},
{ value: 'params', label: 'Params' },
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
],
[],
);
const handleMethodChange = useCallback((method: string) => updateRequest.mutate({ method }), []);
const handleUrlChange = useCallback((url: string) => updateRequest.mutate({ url }), []);
const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []);
const handleHeadersChange = useCallback(
(headers: HttpHeader[]) => updateRequest.mutate({ headers }),
[],
);
if (activeRequest === null) return null;
return (
@@ -35,32 +68,15 @@ export function RequestPane({ fullHeight, className }: Props) {
key={activeRequest.id}
method={activeRequest.method}
url={activeRequest.url}
onMethodChange={(method) => updateRequest.mutate({ method })}
onUrlChange={(url) => updateRequest.mutate({ url })}
onMethodChange={handleMethodChange}
onUrlChange={handleUrlChange}
sendRequest={sendRequest}
loading={responseLoading}
/>
<Tabs
value={activeTab.value}
onChangeValue={activeTab.set}
tabs={[
{
value: 'body',
label: activeRequest.bodyType ?? 'NoBody',
options: {
onValueChange: (bodyType) => updateRequest.mutate({ bodyType: bodyType.value }),
value: activeRequest.bodyType ?? 'nobody',
items: [
{ label: 'No Body', value: 'nobody' },
{ label: 'JSON', value: 'json' },
{ label: 'GraphQL', value: 'graphql' },
],
},
},
{ value: 'params', label: 'Params' },
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
]}
tabs={tabs}
className="mt-2"
label="Request body"
>
@@ -68,7 +84,7 @@ export function RequestPane({ fullHeight, className }: Props) {
<PairEditor
key={activeRequest.id}
pairs={activeRequest.headers}
onChange={(headers) => updateRequest.mutate({ headers })}
onChange={handleHeadersChange}
/>
</TabContent>
<TabContent value="body">
@@ -80,7 +96,7 @@ export function RequestPane({ fullHeight, className }: Props) {
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={activeRequest.body ?? ''}
contentType="application/json"
onChange={(body) => updateRequest.mutate({ body })}
onChange={handleBodyChange}
format={activeRequest.bodyType === 'json' ? (v) => tryFormatJson(v) : undefined}
/>
) : activeRequest.bodyType === 'graphql' ? (
@@ -88,7 +104,7 @@ export function RequestPane({ fullHeight, className }: Props) {
key={activeRequest.id}
className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''}
onChange={(body) => updateRequest.mutate({ body })}
onChange={handleBodyChange}
/>
) : (
<div className="h-full text-gray-400 flex items-center justify-center">No Body</div>

View File

@@ -79,7 +79,6 @@ export function Container({ className }: Props) {
}, []);
const sidebarStyles = useMemo(() => ({ width: width.value }), [width.value]);
console.log('RENDER SIDEBAR');
return (
<div
ref={sidebarRef}
@@ -145,14 +144,16 @@ function SidebarItems({
useEffect(() => {
setItems(requests.map((r) => ({ request: r, left: 0, top: 0 })));
}, [requests.length]);
}, [requests]);
const handleMove = useCallback((id: string, hoverId: string) => {
setItems((oldItems) => {
const dragIndex = oldItems.findIndex((i) => i.request.id === id);
const index = oldItems.findIndex((i) => i.request.id === hoverId);
const newItems = [...oldItems];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const b = newItems[index]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
newItems[index] = newItems[dragIndex]!;
newItems[dragIndex] = b;
return newItems;
@@ -164,7 +165,9 @@ function SidebarItems({
{items.map(({ request }) => (
<DraggableSidebarItem
key={request.id}
request={request}
requestId={request.id}
requestName={request.name}
workspaceId={request.workspaceId}
active={request.id === activeRequestId}
sidebarWidth={sidebarWidth}
onMove={handleMove}
@@ -175,14 +178,22 @@ function SidebarItems({
}
type SidebarItemProps = {
request: HttpRequest;
requestId: string;
requestName: string;
workspaceId: string;
sidebarWidth: number;
active?: boolean;
};
function SidebarItem({ request, active, sidebarWidth }: SidebarItemProps) {
const deleteRequest = useDeleteRequest(request);
const updateRequest = useUpdateRequest(request);
const SidebarItem = memo(function SidebarItem({
requestName,
requestId,
workspaceId,
active,
sidebarWidth,
}: SidebarItemProps) {
const deleteRequest = useDeleteRequest(requestId);
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
const handleSubmitNameEdit = useCallback(async (el: HTMLInputElement) => {
@@ -197,6 +208,8 @@ function SidebarItem({ request, active, sidebarWidth }: SidebarItemProps) {
const itemStyles = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
if (workspaceId === null) return null;
return (
<li className={classnames('block group/item px-2')} style={itemStyles}>
<div className="relative w-full">
@@ -220,7 +233,7 @@ function SidebarItem({ request, active, sidebarWidth }: SidebarItemProps) {
setEditing(true);
}
}}
// to={`/workspaces/${request.workspaceId}/requests/${request.id}`}
to={`/workspaces/${workspaceId}/requests/${requestId}`}
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
@@ -228,7 +241,7 @@ function SidebarItem({ request, active, sidebarWidth }: SidebarItemProps) {
{editing ? (
<input
ref={handleFocus}
defaultValue={request.name}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={async (e) => {
@@ -243,24 +256,22 @@ function SidebarItem({ request, active, sidebarWidth }: SidebarItemProps) {
}}
/>
) : (
<span
className={classnames(
'truncate',
!(request.name || request.url) && 'text-gray-400 italic',
)}
>
{request.name || request.url || 'New Request'}
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
</Button>
<Dropdown
items={[
{
label: 'Delete Request',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
]}
items={useMemo(
() => [
{
label: 'Delete Request',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
],
[],
)}
>
<DropdownMenuTrigger
className={classnames(
@@ -280,7 +291,7 @@ function SidebarItem({ request, active, sidebarWidth }: SidebarItemProps) {
</div>
</li>
);
}
});
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, hoverId: string) => void;
@@ -291,7 +302,9 @@ type DragItem = {
};
const DraggableSidebarItem = memo(function DraggableSidebarItem({
request,
requestName,
requestId,
workspaceId,
active,
sidebarWidth,
onMove,
@@ -302,15 +315,15 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
accept: ItemTypes.REQUEST,
collect: (m) => ({ handlerId: m.getHandlerId(), isOver: m.isOver() }),
hover: (item) => {
if (item.id !== request.id) {
onMove(request.id, item.id);
if (item.id !== requestId) {
onMove(requestId, item.id);
}
},
});
const [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: request.id }),
item: () => ({ id: requestId }),
collect: (m) => ({ isDragging: m.isDragging() }),
}));
@@ -319,7 +332,13 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
return (
<div ref={ref} className={classnames(isDragging && 'opacity-0')}>
<SidebarItem request={request} active={active} sidebarWidth={sidebarWidth} />
<SidebarItem
requestName={requestName}
requestId={requestId}
workspaceId={workspaceId}
active={active}
sidebarWidth={sidebarWidth}
/>
</div>
);
});

View File

@@ -1,8 +1,9 @@
import { useSendRequest } from '../hooks/useSendRequest';
import { useCallback } from 'react';
import { Button } from './core/Button';
import { DropdownMenuRadio, DropdownMenuTrigger } from './core/Dropdown';
import { IconButton } from './core/IconButton';
import { Input } from './core/Input';
import type { TabItem } from './core/Tabs/Tabs';
interface Props {
sendRequest: () => void;
@@ -14,6 +15,12 @@ interface Props {
}
export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChange, url }: Props) {
const handleMethodChange = useCallback(
(v: TabItem) => {
onMethodChange(v.value);
},
[onMethodChange],
);
return (
<form
onSubmit={async (e) => {
@@ -38,7 +45,7 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
placeholder="Enter a URL..."
leftSlot={
<DropdownMenuRadio
onValueChange={(v) => onMethodChange(v.value)}
onValueChange={handleMethodChange}
value={method.toUpperCase()}
items={[
{ label: 'GET', value: 'GET' },

View File

@@ -19,7 +19,7 @@ export default function Workspace() {
const navigate = useNavigate();
const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace();
const deleteRequest = useDeleteRequest(activeRequest);
const deleteRequest = useDeleteRequest(activeRequest?.id ?? null);
const workspaces = useWorkspaces();
const { width } = useWindowSize();
const isSideBySide = width > 900;

View File

@@ -3,7 +3,14 @@ import { CheckIcon } from '@radix-ui/react-icons';
import classnames from 'classnames';
import { motion } from 'framer-motion';
import type { ForwardedRef, ReactElement, ReactNode } from 'react';
import { forwardRef, memo, useImperativeHandle, useLayoutEffect, useState } from 'react';
import {
forwardRef,
memo,
useCallback,
useImperativeHandle,
useLayoutEffect,
useState,
} from 'react';
export interface DropdownMenuRadioItem {
label: string;
@@ -18,19 +25,22 @@ export interface DropdownMenuRadioProps {
items: DropdownMenuRadioItem[];
}
export function DropdownMenuRadio({
export const DropdownMenuRadio = memo(function DropdownMenuRadio({
children,
items,
onValueChange,
label,
value,
}: DropdownMenuRadioProps) {
const handleChange = (value: string) => {
const item = items.find((item) => item.value === value);
if (item && onValueChange) {
onValueChange(item);
}
};
const handleChange = useCallback(
(value: string) => {
const item = items.find((item) => item.value === value);
if (item && onValueChange) {
onValueChange(item);
}
},
[items, onValueChange],
);
return (
<D.Root>
@@ -49,7 +59,7 @@ export function DropdownMenuRadio({
</DropdownMenuPortal>
</D.Root>
);
}
});
export interface DropdownProps {
children: ReactElement<typeof DropdownMenuTrigger>;
@@ -212,7 +222,11 @@ function DropdownMenuItem({
type DropdownMenuRadioItemProps = Omit<D.DropdownMenuRadioItemProps & ItemInnerProps, 'leftSlot'>;
function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRadioItemProps) {
const DropdownMenuRadioItem = memo(function DropdownMenuRadioItem({
rightSlot,
children,
...props
}: DropdownMenuRadioItemProps) {
return (
<D.RadioItem asChild {...props}>
<ItemInner
@@ -227,7 +241,7 @@ function DropdownMenuRadioItem({ rightSlot, children, ...props }: DropdownMenuRa
</ItemInner>
</D.RadioItem>
);
}
});
// const DropdownMenuSubContent = forwardRef<HTMLDivElement, DropdownMenu.DropdownMenuSubContentProps>(
// function DropdownMenuSubContent(
@@ -270,13 +284,17 @@ type DropdownMenuTriggerProps = D.DropdownMenuTriggerProps & {
className?: string;
};
export function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) {
export const DropdownMenuTrigger = memo(function DropdownMenuTrigger({
children,
className,
...props
}: DropdownMenuTriggerProps) {
return (
<D.Trigger asChild className={classnames(className)} {...props}>
{children}
</D.Trigger>
);
}
});
interface ItemInnerProps {
leftSlot?: ReactNode;

View File

@@ -28,6 +28,7 @@ import {
UpdateIcon,
} from '@radix-ui/react-icons';
import classnames from 'classnames';
import { memo } from 'react';
const icons = {
archive: ArchiveIcon,
@@ -67,7 +68,7 @@ export interface IconProps {
spin?: boolean;
}
export function Icon({ icon, spin, size = 'md', className }: IconProps) {
export const Icon = memo(function Icon({ icon, spin, size = 'md', className }: IconProps) {
const Component = icons[icon] ?? icons.question;
return (
<Component
@@ -81,4 +82,4 @@ export function Icon({ icon, spin, size = 'md', className }: IconProps) {
)}
/>
);
}
});

View File

@@ -1,85 +1,46 @@
import classnames from 'classnames';
import type { ComponentType, ReactNode } from 'react';
import { Children, Fragment } from 'react';
const spaceClassesX = {
0: 'pr-0',
1: 'pr-1',
2: 'pr-2',
3: 'pr-3',
4: 'pr-4',
5: 'pr-5',
6: 'pr-6',
};
const spaceClassesY = {
0: 'pt-0',
1: 'pt-1',
2: 'pt-2',
3: 'pt-3',
4: 'pt-4',
5: 'pt-5',
6: 'pt-6',
const gapClasses = {
0: 'gap-0',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
};
interface HStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesX;
children?: ReactNode;
}
export function HStack({ className, space, children, ...props }: HStackProps) {
return (
<BaseStack className={classnames(className, 'flex-row')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesX[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
<BaseStack className={classnames(className, 'flex-row', space && gapClasses[space])} {...props}>
{children}
</BaseStack>
);
}
export interface VStackProps extends BaseStackProps {
space?: keyof typeof spaceClassesY;
children: ReactNode;
}
export function VStack({ className, space, children, ...props }: VStackProps) {
return (
<BaseStack className={classnames(className, 'w-full h-full flex-col')} {...props}>
{space
? Children.toArray(children)
.filter(Boolean) // Remove null/false/undefined children
.map((c, i) => (
<Fragment key={i}>
{i > 0 ? (
<div
className={classnames(spaceClassesY[space], 'pointer-events-none')}
data-spacer=""
aria-hidden
/>
) : null}
{c}
</Fragment>
))
: children}
<BaseStack
className={classnames(className, 'w-full h-full flex-col', space && gapClasses[space])}
{...props}
>
{children}
</BaseStack>
);
}
interface BaseStackProps {
as?: ComponentType | 'ul';
space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end';
className?: string;

View File

@@ -9,19 +9,21 @@ import { HStack } from '../Stacks';
import './Tabs.css';
export type TabItem = {
value: string;
label: string;
options?: {
onValueChange: DropdownMenuRadioProps['onValueChange'];
value: string;
items: DropdownMenuRadioItem[];
};
};
interface Props {
label: string;
onChangeValue: (value: string) => void;
value: string;
tabs: {
value: string;
label: string;
options?: {
onValueChange: DropdownMenuRadioProps['onValueChange'];
value: string;
items: DropdownMenuRadioItem[];
};
}[];
tabs: TabItem[];
tabListClassName?: string;
className?: string;
children: ReactNode;

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useActiveRequestId } from './useActiveRequestId';
import { useRequests } from './useRequests';
export function useActiveRequest(): HttpRequest | null {
const requests = useRequests();
const { requestId } = useParams<{ requestId?: string }>();
const requestId = useActiveRequestId();
const [activeRequest, setActiveRequest] = useState<HttpRequest | null>(null);
useEffect(() => {

View File

@@ -0,0 +1,6 @@
import { useParams } from 'react-router-dom';
export function useActiveRequestId(): string | null {
const { requestId } = useParams<{ requestId?: string }>();
return requestId ?? null;
}

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useWorkspaces } from './useWorkspaces';
export function useActiveWorkspace(): Workspace | null {
const workspaces = useWorkspaces();
const { workspaceId } = useParams<{ workspaceId?: string }>();
const workspaceId = useActiveWorkspaceId();
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null);
useEffect(() => {

View File

@@ -0,0 +1,6 @@
import { useParams } from 'react-router-dom';
export function useActiveWorkspaceId(): string | null {
const { workspaceId } = useParams<{ workspaceId?: string }>();
return workspaceId ?? null;
}

View File

@@ -1,18 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { requestsQueryKey } from './useRequests';
export function useDeleteRequest(request: HttpRequest | null) {
export function useDeleteRequest(id: string | null) {
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (!request) return;
await invoke('delete_request', { requestId: request.id });
if (id === null) return;
await invoke('delete_request', { requestId: id });
},
onSuccess: async () => {
if (!request) return;
await queryClient.invalidateQueries(requestsQueryKey(request.workspaceId));
if (workspaceId === null || id === null) return;
await queryClient.invalidateQueries(requestsQueryKey(workspaceId));
},
});
}

View File

@@ -0,0 +1,7 @@
import type { HttpRequest } from '../lib/models';
import { useRequests } from './useRequests';
export function useRequest(id: string | null): HttpRequest | null {
const requests = useRequests();
return requests.find((r) => r.id === id) ?? null;
}

View File

@@ -1,18 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { responsesQueryKey } from './useResponses';
export function useSendRequest(request: HttpRequest | null) {
export function useSendRequest(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (request == null) return;
await invoke('send_request', { requestId: request.id });
if (id === null) return;
await invoke('send_request', { requestId: id });
},
onSuccess: async () => {
if (request == null) return;
await queryClient.invalidateQueries(responsesQueryKey(request.id));
if (id === null) return;
await queryClient.invalidateQueries(responsesQueryKey(id));
},
}).mutate;
}

View File

@@ -1,4 +1,3 @@
import { app } from '@tauri-apps/api';
import { useEffect } from 'react';
import type { Appearance } from '../lib/theme/window';
import {

View File

@@ -1,8 +1,10 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useRequest } from './useRequest';
export function useUpdateRequest(request: HttpRequest | null) {
export function useUpdateRequest(id: string | null) {
const request = useRequest(id);
return useMutation<void, unknown, Partial<HttpRequest>>({
mutationFn: async (patch) => {
if (request == null) {