Re-order of pair editor

This commit is contained in:
Gregory Schier
2023-03-19 13:28:57 -07:00
parent d9b40dca83
commit 241f2f39ec
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" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title> <title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>--> <script src="http://localhost:8097"></script>
<style> <style>
body { body {
background-color: white; background-color: white;

20
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ import type {
} from 'react'; } from 'react';
import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; import React, { forwardRef, Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd'; import type { XYCoord } from 'react-dnd';
import { DndProvider, useDrag, useDragLayer, useDrop } from 'react-dnd'; import { useDrag, useDragLayer, useDrop } from 'react-dnd';
import { getEmptyImage, HTML5Backend } from 'react-dnd-html5-backend'; import { getEmptyImage } from 'react-dnd-html5-backend';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest'; import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest'; import { useDeleteRequest } from '../hooks/useDeleteRequest';
@@ -39,15 +39,7 @@ enum ItemTypes {
REQUEST = 'request', REQUEST = 'request',
} }
export function Sidebar({ className }: Props) { export const Sidebar = memo(function Sidebar({ className }: Props) {
return (
<DndProvider backend={HTML5Backend}>
<Container className={className} />
</DndProvider>
);
}
export function Container({ className }: Props) {
const [isResizing, setIsRisizing] = useState<boolean>(false); const [isResizing, setIsRisizing] = useState<boolean>(false);
const width = useKeyValue<number>({ key: 'sidebar_width', initialValue: INITIAL_WIDTH }); const width = useKeyValue<number>({ key: 'sidebar_width', initialValue: INITIAL_WIDTH });
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
@@ -94,13 +86,13 @@ export function Container({ className }: Props) {
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div <div
aria-hidden 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} onMouseDown={handleResizeStart}
onDoubleClick={handleResizeReset} onDoubleClick={handleResizeReset}
> >
<div // drag-divider <div // drag-divider
className={classnames( 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', isResizing && '!bg-blue-500/70',
)} )}
/> />
@@ -143,7 +135,7 @@ export function Container({ className }: Props) {
</div> </div>
</div> </div>
); );
} });
function SidebarItems({ function SidebarItems({
requests: unorderedRequests, requests: unorderedRequests,
@@ -169,8 +161,6 @@ function SidebarItems({
[requests], [requests],
); );
const handleCancel = useCallback(() => setHoveredIndex(null), []);
const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>( const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
(requestId) => { (requestId) => {
if (hoveredIndex === null) return; if (hoveredIndex === null) return;
@@ -181,11 +171,8 @@ function SidebarItems({
if (request === undefined) return; if (request === undefined) return;
const newRequests = requests.filter((r) => r.id !== requestId); const newRequests = requests.filter((r) => r.id !== requestId);
if (hoveredIndex > index) { if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request);
newRequests.splice(hoveredIndex - 1, 0, request); else newRequests.splice(hoveredIndex, 0, request);
} else {
newRequests.splice(hoveredIndex, 0, request);
}
const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0; const beforePriority = newRequests[hoveredIndex - 1]?.sortPriority ?? 0;
const afterPriority = newRequests[hoveredIndex + 1]?.sortPriority ?? 0; const afterPriority = newRequests[hoveredIndex + 1]?.sortPriority ?? 0;
@@ -220,7 +207,6 @@ function SidebarItems({
sidebarWidth={sidebarWidth} sidebarWidth={sidebarWidth}
onMove={handleMove} onMove={handleMove}
onEnd={handleEnd} onEnd={handleEnd}
onCancel={handleCancel}
/> />
</Fragment> </Fragment>
); );
@@ -369,7 +355,6 @@ const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & { type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, side: 'above' | 'below') => void; onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void; onEnd: (id: string) => void;
onCancel: () => void;
}; };
type DragItem = { type DragItem = {
@@ -386,23 +371,23 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
sidebarWidth, sidebarWidth,
onMove, onMove,
onEnd, onEnd,
onCancel,
}: DraggableSidebarItemProps) { }: DraggableSidebarItemProps) {
const ref = useRef<HTMLLIElement>(null); const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>({ const [, connectDrop] = useDrop<DragItem, void>(
accept: ItemTypes.REQUEST, {
collect: (m) => ({ handlerId: m.getHandlerId(), isOver: m.isOver() }), accept: ItemTypes.REQUEST,
hover: (item, monitor) => { hover: (item, monitor) => {
if (!ref.current) return; if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect(); const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const clientOffset = monitor.getClientOffset();
const clientOffset = monitor.getClientOffset(); const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below');
onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below'); },
}, },
}); [onMove],
);
const [{ isDragging }, connectDrag, preview] = useDrag< const [{ isDragging }, connectDrag, preview] = useDrag<
DragItem, DragItem,
@@ -414,10 +399,7 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
item: () => ({ id: requestId, requestName, workspaceId }), item: () => ({ id: requestId, requestName, workspaceId }),
collect: (m) => ({ isDragging: m.isDragging() }), collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' }, options: { dropEffect: 'move' },
end: () => { end: () => onEnd(requestId),
// TODO: Call cancel if dropped outside of sidebar
onEnd(requestId);
},
}), }),
[onEnd], [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 { useIsResponseLoading } from '../hooks/useIsResponseLoading';
import { useSendRequest } from '../hooks/useSendRequest'; import { useSendRequest } from '../hooks/useSendRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest';
@@ -9,30 +11,31 @@ import { RequestMethodDropdown } from './RequestMethodDropdown';
interface Props { interface Props {
request: HttpRequest; 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 sendRequest = useSendRequest(request.id);
const updateRequest = useUpdateRequest(request.id); const updateRequest = useUpdateRequest(request.id);
const handleMethodChange = useCallback((method: string) => updateRequest.mutate({ method }), []); const handleMethodChange = useCallback((method: string) => updateRequest.mutate({ method }), []);
const handleUrlChange = useCallback((url: string) => updateRequest.mutate({ url }), []); const handleUrlChange = useCallback((url: string) => updateRequest.mutate({ url }), []);
const loading = useIsResponseLoading(request.id); const loading = useIsResponseLoading(request.id);
const useEditor = useMemo(() => ({ useTemplating: true, contentType: 'url' }), []);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
sendRequest();
},
[sendRequest],
);
return ( return (
<form <form onSubmit={handleSubmit} className={classnames(className, 'w-full flex items-center')}>
onSubmit={async (e) => {
e.preventDefault();
sendRequest();
}}
className="w-full flex items-center"
>
<Input <Input
key={request.id} key={request.id}
hideLabel hideLabel
useEditor={{ useEditor={useEditor}
useTemplating: true,
contentType: 'url',
autocompleteOptions: [{ label: 'FOO', type: 'constant' }],
}}
className="px-0" className="px-0"
name="url" name="url"
label="Enter URL" label="Enter URL"

View File

@@ -72,9 +72,9 @@ export default function Workspace() {
> >
<RequestPane <RequestPane
fullHeight={isSideBySide} 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> </div>
</div> </div>

View File

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

View File

@@ -1,9 +1,12 @@
import classnames from 'classnames'; 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 type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Icon } from './Icon';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
import { Input } from './Input'; import { Input } from './Input';
import { VStack } from './Stacks';
export type PairEditorProps = { export type PairEditorProps = {
pairs: Pair[]; pairs: Pair[];
@@ -34,10 +37,11 @@ export const PairEditor = memo(function PairEditor({
className, className,
onChange, onChange,
}: PairEditorProps) { }: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [pairs, setPairs] = useState<PairContainer[]>(() => { const [pairs, setPairs] = useState<PairContainer[]>(() => {
// Remove empty headers on initial render // Remove empty headers on initial render
const nonEmpty = originalPairs.filter((h) => !(h.name === '' && h.value === '')); 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()]; return [...pairs, newPairContainer()];
}); });
@@ -52,6 +56,33 @@ export const PairEditor = memo(function PairEditor({
[onChange], [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) => { const handleChangeHeader = useCallback((pair: PairContainer) => {
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))); setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair)));
}, []); }, []);
@@ -78,13 +109,20 @@ export const PairEditor = memo(function PairEditor({
); );
return ( return (
<div className={classnames(className, 'pb-6 grid')}> <div
<VStack space={2}> className={classnames(
{pairs.map((p, i) => { className,
const isLast = i === pairs.length - 1; 'pb-6 grid',
return ( // 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 <FormRow
key={p.id}
pairContainer={p} pairContainer={p}
isLast={isLast} isLast={isLast}
onChange={handleChangeHeader} onChange={handleChangeHeader}
@@ -94,16 +132,25 @@ export const PairEditor = memo(function PairEditor({
valuePlaceholder={valuePlaceholder} valuePlaceholder={valuePlaceholder}
onFocus={handleFocus} onFocus={handleFocus}
onDelete={isLast ? undefined : handleDelete} onDelete={isLast ? undefined : handleDelete}
onEnd={handleEnd}
onMove={handleMove}
/> />
); {hoveredIndex === pairs.length && <DropMarker />}
})} </Fragment>
</VStack> );
})}
</div> </div>
); );
}); });
enum ItemTypes {
ROW = 'pair-row',
}
type FormRowProps = { type FormRowProps = {
pairContainer: PairContainer; pairContainer: PairContainer;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onChange: (pair: PairContainer) => void; onChange: (pair: PairContainer) => void;
onDelete?: (pair: PairContainer) => void; onDelete?: (pair: PairContainer) => void;
onFocus?: (pair: PairContainer) => void; onFocus?: (pair: PairContainer) => void;
@@ -118,6 +165,8 @@ const FormRow = memo(function FormRow({
onChange, onChange,
onDelete, onDelete,
onFocus, onFocus,
onMove,
onEnd,
isLast, isLast,
nameAutocomplete, nameAutocomplete,
valueAutocomplete, valueAutocomplete,
@@ -125,6 +174,7 @@ const FormRow = memo(function FormRow({
valuePlaceholder, valuePlaceholder,
}: FormRowProps) { }: FormRowProps) {
const { id } = pairContainer; const { id } = pairContainer;
const ref = useRef<HTMLDivElement>(null);
const handleChangeName = useMemo( const handleChangeName = useMemo(
() => (name: string) => onChange({ id, pair: { name, value: pairContainer.pair.value } }), () => (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 handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
const handleDelete = useCallback(() => onDelete?.(pairContainer), [onDelete, 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 ( 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 <Input
hideLabel hideLabel
containerClassName={classnames(isLast && 'border-dashed')} containerClassName={classnames(isLast && 'border-dashed')}
@@ -188,6 +281,17 @@ const FormRow = memo(function FormRow({
); );
}); });
const newPairContainer = (): PairContainer => { const newPairContainer = (pair?: Pair): PairContainer => {
return { pair: { name: '', value: '' }, id: Math.random().toString() }; 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 <T.List
aria-label={label} aria-label={label}
className={classnames( className={classnames(tabListClassName, 'h-auto flex items-center overflow-x-auto pb-1')}
tabListClassName,
'h-auto flex items-center overflow-x-auto pb-1 mb-1',
)}
> >
<HStack space={1}> <HStack space={1}>
{tabs.map((t) => { {tabs.map((t) => {