Re-order of pair editor

This commit is contained in:
Gregory Schier
2023-03-19 13:28:57 -07:00
parent 05f20af9ed
commit 7314c8f36f
12 changed files with 195 additions and 88 deletions

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
<script src="http://localhost:8097"></script>
<style>
body {
background-color: white;

20
package-lock.json generated
View File

@@ -41,7 +41,8 @@
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-router-dom": "^6.8.1",
"react-use": "^17.4.0"
"react-use": "^17.4.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
@@ -50,6 +51,7 @@
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react-dom": "^18.0.6",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.13",
@@ -2347,6 +2349,12 @@
"integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
"dev": true
},
"node_modules/@types/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.52.0.tgz",
@@ -7650,7 +7658,6 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -9639,6 +9646,12 @@
"integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
"dev": true
},
"@types/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "5.52.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.52.0.tgz",
@@ -13463,8 +13476,7 @@
"uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"dev": true
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
},
"vite": {
"version": "4.1.1",

View File

@@ -48,7 +48,8 @@
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-router-dom": "^6.8.1",
"react-use": "^17.4.0"
"react-use": "^17.4.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
@@ -57,6 +58,7 @@
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react-dom": "^18.0.6",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.13",

View File

@@ -3,6 +3,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { invoke } from '@tauri-apps/api';
import { listen } from '@tauri-apps/api/event';
import { MotionConfig } from 'framer-motion';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { matchPath } from 'react-router-dom';
import { keyValueQueryKey } from '../hooks/useKeyValue';
@@ -123,8 +125,10 @@ export function App() {
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<AppRouter />
<ReactQueryDevtools initialIsOpen={false} />
<DndProvider backend={HTML5Backend}>
<AppRouter />
<ReactQueryDevtools initialIsOpen={false} />
</DndProvider>
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>

View File

@@ -60,13 +60,14 @@ export function RequestPane({ fullHeight, className }: Props) {
if (activeRequest === null) return null;
return (
<div className={classnames(className, 'p-2 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
<UrlBar request={activeRequest} />
<div className={classnames(className, 'py-3 grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}>
<UrlBar className="pl-3" request={activeRequest} />
<Tabs
value={activeTab.value}
onChangeValue={activeTab.set}
tabs={tabs}
className="mt-2"
tabListClassName="pl-3"
label="Request body"
>
<TabContent value="headers">
@@ -79,7 +80,7 @@ export function RequestPane({ fullHeight, className }: Props) {
<TabContent value="params">
<ParametersEditor key={activeRequestId} parameters={[]} onChange={() => null} />
</TabContent>
<TabContent value="body">
<TabContent value="body" className="mt-1">
{activeRequest.bodyType === 'json' ? (
<Editor
key={activeRequest.id}

View File

@@ -47,7 +47,7 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
}
return (
<div className={classnames(className, 'p-2')}>
<div className={classnames(className, 'p-3')}>
<div
className={classnames(
'max-h-full h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 ',
@@ -113,7 +113,7 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
{activeResponse?.error ? (
<div className="p-1">
<div className="text-white bg-red-500 px-3 py-2 rounded">{activeResponse.error}</div>
<div className="text-white bg-red-500 px-3 py-3 rounded">{activeResponse.error}</div>
</div>
) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview body={activeResponse.body} contentType={contentType} url={activeResponse.url} />

View File

@@ -7,8 +7,8 @@ import type {
} from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { DndProvider, useDrag, useDragLayer, useDrop } from 'react-dnd';
import { getEmptyImage, HTML5Backend } from 'react-dnd-html5-backend';
import { useDrag, useDragLayer, useDrop } from 'react-dnd';
import { getEmptyImage } from 'react-dnd-html5-backend';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
@@ -39,15 +39,7 @@ enum ItemTypes {
REQUEST = 'request',
}
export function Sidebar({ className }: Props) {
return (
<DndProvider backend={HTML5Backend}>
<Container className={className} />
</DndProvider>
);
}
export function Container({ className }: Props) {
export const Sidebar = memo(function Sidebar({ className }: Props) {
const [isResizing, setIsRisizing] = useState<boolean>(false);
const width = useKeyValue<number>({ key: 'sidebar_width', initialValue: INITIAL_WIDTH });
const sidebarRef = useRef<HTMLDivElement>(null);
@@ -94,13 +86,13 @@ export function Container({ className }: Props) {
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
aria-hidden
className="group absolute -right-2 top-0 bottom-0 w-4 cursor-ew-resize flex justify-center"
className="group absolute z-10 right-0 w-1 top-0 bottom-0 cursor-ew-resize flex justify-end"
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeReset}
>
<div // drag-divider
className={classnames(
'transition-colors w-[1px] group-hover:bg-gray-300 h-full pointer-events-none',
'transition-colors w-0.5 group-hover:bg-gray-300 h-full pointer-events-none',
isResizing && '!bg-blue-500/70',
)}
/>
@@ -143,7 +135,7 @@ export function Container({ className }: Props) {
</div>
</div>
);
}
});
function SidebarItems({
requests: unorderedRequests,
@@ -169,8 +161,6 @@ function SidebarItems({
[requests],
);
const handleCancel = useCallback(() => setHoveredIndex(null), []);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
(requestId) => {
if (hoveredIndex === null) return;
@@ -181,11 +171,8 @@ function SidebarItems({
if (request === undefined) return;
const newRequests = requests.filter((r) => r.id !== requestId);
if (hoveredIndex > index) {
newRequests.splice(hoveredIndex - 1, 0, request);
} else {
newRequests.splice(hoveredIndex, 0, request);
}
if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
else newRequests.splice(hoveredIndex, 0, request);
const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0;
const afterPriority = newRequests[hoveredIndex + 1]?.sortPriority ?? 0;
@@ -220,7 +207,6 @@ function SidebarItems({
sidebarWidth={sidebarWidth}
onMove={handleMove}
onEnd={handleEnd}
onCancel={handleCancel}
/>
</Fragment>
);
@@ -369,7 +355,6 @@ const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onCancel: () => void;
};
type DragItem = {
@@ -386,23 +371,23 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
sidebarWidth,
onMove,
onEnd,
onCancel,
}: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>({
accept: ItemTypes.REQUEST,
collect: (m) => ({ handlerId: m.getHandlerId(), isOver: m.isOver() }),
hover: (item, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (item, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
});
[onMove],
);
const [{ isDragging }, connectDrag, preview] = useDrag<
DragItem,
@@ -414,10 +399,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
item: () => ({ id: requestId, requestName, workspaceId }),
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => {
// TODO: Call cancel if dropped outside of sidebar
onEnd(requestId);
},
end: () => onEnd(requestId),
}),
[onEnd],
);

View File

@@ -1,4 +1,6 @@
import { memo, useCallback } from 'react';
import classnames from 'classnames';
import type { FormEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
@@ -9,30 +11,31 @@ import { RequestMethodDropdown } from './RequestMethodDropdown';
interface Props {
request: HttpRequest;
className?: string;
}
export const UrlBar = memo(function UrlBar({ request }: Props) {
export const UrlBar = memo(function UrlBar({ request, className }: Props) {
const sendRequest = useSendRequest(request.id);
const updateRequest = useUpdateRequest(request.id);
const handleMethodChange = useCallback((method: string) => updateRequest.mutate({ method }), []);
const handleUrlChange = useCallback((url: string) => updateRequest.mutate({ url }), []);
const loading = useIsResponseLoading(request.id);
const useEditor = useMemo(() => ({ useTemplating: true, contentType: 'url' }), []);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
sendRequest();
},
[sendRequest],
);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
sendRequest();
}}
className="w-full flex items-center"
>
<form onSubmit={handleSubmit} className={classnames(className, 'w-full flex items-center')}>
<Input
key={request.id}
hideLabel
useEditor={{
useTemplating: true,
contentType: 'url',
autocompleteOptions: [{ label: 'FOO', type: 'constant' }],
}}
useEditor={useEditor}
className="px-0"
name="url"
label="Enter URL"

View File

@@ -72,9 +72,9 @@ export default function Workspace() {
>
<RequestPane
fullHeight={isSideBySide}
className={classnames(isSideBySide ? 'pr-1' : 'pr-2')}
className={classnames(isSideBySide ? 'pr-1.5' : 'pr-3')}
/>
<ResponsePane className={classnames(isSideBySide && 'pl-1')} />
<ResponsePane className={classnames(isSideBySide && 'pl-1.5')} />
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import {
Cross2Icon,
DotsHorizontalIcon,
DotsVerticalIcon,
DragHandleDots2Icon,
EyeOpenIcon,
GearIcon,
HomeIcon,
@@ -39,6 +40,7 @@ const icons = {
colorWheel: ColorWheelIcon,
dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon,
drag: DragHandleDots2Icon,
eye: EyeOpenIcon,
gear: GearIcon,
home: HomeIcon,

View File

@@ -1,9 +1,12 @@
import classnames from 'classnames';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid';
import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { Input } from './Input';
import { VStack } from './Stacks';
export type PairEditorProps = {
pairs: Pair[];
@@ -34,10 +37,11 @@ export const PairEditor = memo(function PairEditor({
className,
onChange,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<PairContainer[]>(() => {
// Remove empty headers on initial render
const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === ''));
const pairs = nonEmpty.map((h) => ({ pair: h, id: Math.random().toString() }));
const pairs = nonEmpty.map((pair) => newPairContainer(pair));
return [...pairs, newPairContainer()];
});
@@ -52,6 +56,33 @@ export const PairEditor = memo(function PairEditor({
[onChange],
);
const handleMove = useCallback<FormRowProps['onMove']>(
(id, side) => {
const dragIndex = pairs.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
},
[pairs],
);
const handleEnd = useCallback<FormRowProps['onEnd']>(
(id: string) => {
if (hoveredIndex === null) return;
setHoveredIndex(null);
setPairsAndSave((pairs) => {
const index = pairs.findIndex((p) => p.id === id);
const pair = pairs[index];
if (pair === undefined) return pairs;
const newPairs = pairs.filter((p) => p.id !== id);
if (hoveredIndex > index) newPairs.splice(hoveredIndex - 1, 0, pair);
else newPairs.splice(hoveredIndex, 0, pair);
return newPairs;
});
},
[hoveredIndex],
);
const handleChangeHeader = useCallback((pair: PairContainer) => {
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair)));
}, []);
@@ -78,13 +109,20 @@ export const PairEditor = memo(function PairEditor({
);
return (
<div className={classnames(className, 'pb-6 grid')}>
<VStack space={2}>
{pairs.map((p, i) => {
const isLast = i === pairs.length - 1;
return (
<div
className={classnames(
className,
'pb-6 grid',
// NOTE: Add padding to top so overflow doesn't hide drop marker
'py-1',
)}
>
{pairs.map((p, i) => {
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<FormRow
key={p.id}
pairContainer={p}
isLast={isLast}
onChange={handleChangeHeader}
@@ -94,16 +132,25 @@ export const PairEditor = memo(function PairEditor({
valuePlaceholder={valuePlaceholder}
onFocus={handleFocus}
onDelete={isLast ? undefined : handleDelete}
onEnd={handleEnd}
onMove={handleMove}
/>
);
})}
</VStack>
{hoveredIndex === pairs.length && <DropMarker />}
</Fragment>
);
})}
</div>
);
});
enum ItemTypes {
ROW = 'pair-row',
}
type FormRowProps = {
pairContainer: PairContainer;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void;
onDelete?: (pair: PairContainer) => void;
onFocus?: (pair: PairContainer) => void;
@@ -118,6 +165,8 @@ const FormRow = memo(function FormRow({
onChange,
onDelete,
onFocus,
onMove,
onEnd,
isLast,
nameAutocomplete,
valueAutocomplete,
@@ -125,6 +174,7 @@ const FormRow = memo(function FormRow({
valuePlaceholder,
}: FormRowProps) {
const { id } = pairContainer;
const ref = useRef<HTMLDivElement>(null);
const handleChangeName = useMemo(
() => (name: string) => onChange({ id, pair: { name, value: pairContainer.pair.value } }),
@@ -149,8 +199,51 @@ const FormRow = memo(function FormRow({
const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
const handleDelete = useCallback(() => onDelete?.(pairContainer), [onDelete, pairContainer]);
const [, connectDrop] = useDrop<PairContainer>(
{
accept: ItemTypes.ROW,
hover: (item, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(pairContainer.id, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [, connectDrag] = useDrag<PairContainer>(
{
type: ItemTypes.ROW,
item: () => pairContainer,
collect: (m) => ({ isDragging: m.isDragging() }),
end: () => onEnd(pairContainer.id),
},
[pairContainer, onEnd],
);
connectDrag(ref);
connectDrop(ref);
return (
<div className="group grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] grid-rows-1 gap-2 items-center">
<div
ref={ref}
className="pb-2 group grid grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto] grid-rows-1 gap-2 items-center"
>
{!isLast ? (
<div
className={classnames(
'-mr-2 py-2 h-9 w-3 flex items-center',
'justify-center opacity-0 hover:opacity-100',
)}
>
<Icon icon="drag" className="pointer-events-none" />
</div>
) : (
<span className="w-1" />
)}
<Input
hideLabel
containerClassName={classnames(isLast && 'border-dashed')}
@@ -188,6 +281,17 @@ const FormRow = memo(function FormRow({
);
});
const newPairContainer = (): PairContainer => {
return { pair: { name: '', value: '' }, id: Math.random().toString() };
const newPairContainer = (pair?: Pair): PairContainer => {
return { pair: pair ?? { name: '', value: '' }, id: uuid() };
};
const DropMarker = memo(
function DropMarker() {
return (
<div className="relative w-full h-0 overflow-visible pointer-events-none">
<div className="absolute z-50 left-0 right-0 bottom-[1px] h-[0.2em] bg-blue-500/50 rounded-full" />
</div>
);
},
() => true,
);

View File

@@ -47,10 +47,7 @@ export const Tabs = memo(function Tabs({
>
<T.List
aria-label={label}
className={classnames(
tabListClassName,
'h-auto flex items-center overflow-x-auto pb-1 mb-1',
)}
className={classnames(tabListClassName, 'h-auto flex items-center overflow-x-auto pb-1')}
>
<HStack space={1}>
{tabs.map((t) => {