Persist sort priority!

This commit is contained in:
Gregory Schier
2023-03-19 00:48:09 -07:00
parent d9b38efd97
commit c4ab045e57
17 changed files with 457 additions and 243 deletions

Binary file not shown.

View File

@@ -0,0 +1 @@
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;

View File

@@ -78,7 +78,7 @@
}, },
"query": "\n DELETE FROM http_requests\n WHERE id = ?\n " "query": "\n DELETE FROM http_requests\n WHERE id = ?\n "
}, },
"68b7b17a25d415ce90b33aef16418d668f7ff6275b383bf21c16cafb38cca342": { "539bb11d635c0295f969d32c6bf1e3d78f2686521a5ef2a4af661b7e645f58c1": {
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -132,8 +132,13 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>", "name": "sort_priority",
"ordinal": 10, "ordinal": 10,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 11,
"type_info": "Text" "type_info": "Text"
} }
], ],
@@ -148,23 +153,14 @@
false, false,
true, true,
true, true,
false,
false false
], ],
"parameters": { "parameters": {
"Right": 1 "Right": 1
} }
}, },
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n " "query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE workspace_id = ?\n "
},
"913f3c3a46b1834c4cd8367aed9d5a9659a1d775d8771e9f5bf9a5aa41197356": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 8
}
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n headers\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n url = excluded.url\n "
}, },
"a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": { "a83698dcf9a815b881097133edb31a34ba25e7c6c114d463c495342a85371639": {
"describe": { "describe": {
@@ -372,84 +368,6 @@
}, },
"query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n " "query": "\n INSERT INTO key_values (namespace, key, value)\n VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n value = excluded.value\n "
}, },
"d9ea350bc21ac2f51f6dcb9713328ec330f0e12105da70bf5a6eff9601e32a85": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 10,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": { "e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
"describe": { "describe": {
"columns": [ "columns": [
@@ -540,6 +458,90 @@
}, },
"query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n " "query": "\n SELECT id, model, workspace_id, request_id, updated_at, created_at,\n status, status_reason, body, elapsed, url, error,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpResponseHeader>>\"\n FROM http_responses\n WHERE id = ?\n "
}, },
"e523dc91256b4409a734850eae59ac73b951177ce88d35e2ab708871f3067ace": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "workspace_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 4,
"type_info": "Datetime"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "url",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "method",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "body",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "body_type",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "sort_priority",
"ordinal": 10,
"type_info": "Float"
},
{
"name": "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>",
"ordinal": 11,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "\n SELECT\n id,\n model,\n workspace_id,\n created_at,\n updated_at,\n name,\n url,\n method,\n body,\n body_type,\n sort_priority,\n headers AS \"headers!: sqlx::types::Json<Vec<HttpRequestHeader>>\"\n FROM http_requests\n WHERE id = ?\n "
},
"e767522f92c8c49cd2e563e58737a05092daf9b1dc763bacc82a5c14d696d78e": { "e767522f92c8c49cd2e563e58737a05092daf9b1dc763bacc82a5c14d696d78e": {
"describe": { "describe": {
"columns": [], "columns": [],
@@ -559,5 +561,15 @@
} }
}, },
"query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n " "query": "\n INSERT INTO workspaces (id, name, description)\n VALUES (?, ?, ?)\n "
},
"f506d6b1451d95489cf41fea2d1cd3fae4f0773e16ae11ded6fd5923f015c8d5": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 9
}
},
"query": "\n INSERT INTO http_requests (\n id,\n workspace_id,\n name,\n url,\n method,\n body,\n body_type,\n headers,\n sort_priority\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n method = excluded.method,\n headers = excluded.headers,\n body = excluded.body,\n body_type = excluded.body_type,\n url = excluded.url,\n sort_priority = excluded.sort_priority\n "
} }
} }

View File

@@ -247,13 +247,14 @@ async fn create_workspace(
async fn create_request( async fn create_request(
workspace_id: &str, workspace_id: &str,
name: &str, name: &str,
sort_priority: f64,
app_handle: AppHandle<Wry>, app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<String, String> { ) -> Result<String, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
let headers = Vec::new(); let headers = Vec::new();
let created_request = let created_request =
models::upsert_request(None, workspace_id, name, "GET", None, None, "", headers, pool) models::upsert_request(None, workspace_id, name, "GET", None, None, "", headers, sort_priority, pool)
.await .await
.expect("Failed to create request"); .expect("Failed to create request");
@@ -291,6 +292,7 @@ async fn update_request(
request.body_type, request.body_type,
request.url.as_str(), request.url.as_str(),
request.headers.0, request.headers.0,
request.sort_priority,
pool, pool,
) )
.await .await

View File

@@ -29,6 +29,7 @@ pub struct HttpRequest {
pub model: String, pub model: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub sort_priority: f64,
pub workspace_id: String, pub workspace_id: String,
pub name: String, pub name: String,
pub url: String, pub url: String,
@@ -170,6 +171,7 @@ pub async fn upsert_request(
body_type: Option<String>, body_type: Option<String>,
url: &str, url: &str,
headers: Vec<HttpRequestHeader>, headers: Vec<HttpRequestHeader>,
sort_priority: f64,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> { ) -> Result<HttpRequest, sqlx::Error> {
let generated_id; let generated_id;
@@ -191,9 +193,10 @@ pub async fn upsert_request(
method, method,
body, body,
body_type, body_type,
headers headers,
sort_priority
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP,
name = excluded.name, name = excluded.name,
@@ -201,7 +204,8 @@ pub async fn upsert_request(
headers = excluded.headers, headers = excluded.headers,
body = excluded.body, body = excluded.body,
body_type = excluded.body_type, body_type = excluded.body_type,
url = excluded.url url = excluded.url,
sort_priority = excluded.sort_priority
"#, "#,
id, id,
workspace_id, workspace_id,
@@ -211,6 +215,7 @@ pub async fn upsert_request(
body, body,
body_type, body_type,
headers_json, headers_json,
sort_priority,
) )
.execute(pool) .execute(pool)
.await .await
@@ -236,6 +241,7 @@ pub async fn find_requests(
method, method,
body, body,
body_type, body_type,
sort_priority,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>" headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests FROM http_requests
WHERE workspace_id = ? WHERE workspace_id = ?
@@ -261,6 +267,7 @@ pub async fn get_request(id: &str, pool: &Pool<Sqlite>) -> Result<HttpRequest, s
method, method,
body, body,
body_type, body_type,
sort_priority,
headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>" headers AS "headers!: sqlx::types::Json<Vec<HttpRequestHeader>>"
FROM http_requests FROM http_requests
WHERE id = ? WHERE id = ?

View File

@@ -1,13 +1,20 @@
import classnames from 'classnames'; import classnames from 'classnames';
import type { MouseEvent as ReactMouseEvent } from 'react'; import type {
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; CSSProperties,
import { DndProvider, useDrag, useDrop } from 'react-dnd'; ForwardedRef,
KeyboardEvent,
MouseEvent as ReactMouseEvent,
} 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 { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } 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';
import { useKeyValue } from '../hooks/useKeyValue'; import { useKeyValue } from '../hooks/useKeyValue';
import { useRequests } from '../hooks/useRequests'; import { useRequests } from '../hooks/useRequests';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { clamp } from '../lib/clamp'; import { clamp } from '../lib/clamp';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
@@ -15,6 +22,7 @@ import { Button } from './core/Button';
import { Dropdown, DropdownMenuTrigger } from './core/Dropdown'; import { Dropdown, DropdownMenuTrigger } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { ScrollArea } from './core/ScrollArea';
import { HStack, VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion'; import { WindowDragRegion } from './core/WindowDragRegion';
import { ToggleThemeButton } from './ToggleThemeButton'; import { ToggleThemeButton } from './ToggleThemeButton';
@@ -82,15 +90,7 @@ export function Container({ className }: Props) {
const sidebarWidth = width.value - 1; // Minus 1 for the border const sidebarWidth = width.value - 1; // Minus 1 for the border
return ( return (
<div <div className="relative">
ref={sidebarRef}
style={sidebarStyles}
className={classnames(
className,
'relative',
'bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto,1fr,auto]',
)}
>
{/* 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
@@ -105,32 +105,48 @@ export function Container({ className }: Props) {
)} )}
/> />
</div> </div>
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end"> <div
<IconButton ref={sidebarRef}
title="Add Request" style={sidebarStyles}
className="mx-1" className={classnames(
icon="plusCircle" className,
onClick={async () => { 'bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto_minmax(0,1fr)_auto]',
await createRequest.mutate({ name: 'Test Request' }); )}
}} >
/> <HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
</HStack> <IconButton
<VStack as="ul" className="py-3 overflow-auto h-full" space={1}> title="Add Request"
<SidebarItems className="mx-1"
sidebarWidth={sidebarWidth} icon="plusCircle"
activeRequestId={activeRequest?.id} onClick={async () => {
requests={requests} const lastRequest = requests[requests.length - 1];
/> await createRequest.mutate({
</VStack> name: 'Test Request',
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end"> sortPriority: lastRequest?.sortPriority ?? 0 + 1,
<ToggleThemeButton /> });
</HStack> }}
/>
</HStack>
<ScrollArea>
<VStack as="ul" className="relative py-3" draggable={false}>
<SidebarItems
sidebarWidth={sidebarWidth}
activeRequestId={activeRequest?.id}
requests={requests}
/>
{/*<CustomDragLayer sidebarWidth={sidebarWidth} />*/}
</VStack>
</ScrollArea>
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
<ToggleThemeButton />
</HStack>
</div>
</div> </div>
); );
} }
function SidebarItems({ function SidebarItems({
requests, requests: unorderedRequests,
activeRequestId, activeRequestId,
sidebarWidth, sidebarWidth,
}: { }: {
@@ -138,44 +154,85 @@ function SidebarItems({
activeRequestId?: string; activeRequestId?: string;
sidebarWidth: number; sidebarWidth: number;
}) { }) {
const [items, setItems] = useState(requests.map((r) => ({ request: r, left: 0, top: 0 }))); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const updateRequest = useUpdateAnyRequest();
const requests = useMemo(
() => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority),
[unorderedRequests],
);
useEffect(() => { const handleMove = useCallback<DraggableSidebarItemProps['onMove']>(
setItems(requests.map((r) => ({ request: r, left: 0, top: 0 }))); (id, side) => {
}, [requests]); const dragIndex = requests.findIndex((r) => r.id === id);
setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1);
},
[requests],
);
const handleMove = useCallback((id: string, hoverId: string) => { const handleCancel = useCallback(() => setHoveredIndex(null), []);
setItems((oldItems) => {
const dragIndex = oldItems.findIndex((i) => i.request.id === id); const handleEnd = useCallback<DraggableSidebarItemProps['onEnd']>(
const index = oldItems.findIndex((i) => i.request.id === hoverId); (requestId) => {
const newItems = [...oldItems]; if (hoveredIndex === null) return;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion setHoveredIndex(null);
const b = newItems[index]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const index = requests.findIndex((r) => r.id === requestId);
newItems[index] = newItems[dragIndex]!; const request = requests[index];
newItems[dragIndex] = b; if (request === undefined) return;
return newItems;
}); const newRequests = requests.filter((r) => r.id !== requestId);
}, []); 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;
const shouldUpdateAll = afterPriority - beforePriority < 1;
if (shouldUpdateAll) {
newRequests.forEach((r, i) => {
updateRequest.mutate({ id: r.id, sortPriority: i * 1000 });
});
} else {
updateRequest.mutate({
id: requestId,
sortPriority: afterPriority - (afterPriority - beforePriority) / 2,
});
}
},
[hoveredIndex, requests],
);
return ( return (
<> <>
{items.map(({ request }) => ( {requests.map((r, i) => {
<DraggableSidebarItem return (
key={request.id} <Fragment key={r.id}>
requestId={request.id} {hoveredIndex === i && <DropMarker />}
requestName={request.name} <DraggableSidebarItem
workspaceId={request.workspaceId} key={r.id}
active={request.id === activeRequestId} requestId={r.id}
sidebarWidth={sidebarWidth} requestName={r.name}
onMove={handleMove} workspaceId={r.workspaceId}
/> active={r.id === activeRequestId}
))} sidebarWidth={sidebarWidth}
onMove={handleMove}
onEnd={handleEnd}
onCancel={handleCancel}
/>
</Fragment>
);
})}
{hoveredIndex === requests.length && <DropMarker />}
</> </>
); );
} }
type SidebarItemProps = { type SidebarItemProps = {
className?: string;
buttonClassName?: string;
requestId: string; requestId: string;
requestName: string; requestName: string;
workspaceId: string; workspaceId: string;
@@ -183,13 +240,18 @@ type SidebarItemProps = {
active?: boolean; active?: boolean;
}; };
const SidebarItem = memo(function SidebarItem({ const _SidebarItem = forwardRef(function SidebarItem(
requestName, {
requestId, className,
workspaceId, buttonClassName,
active, requestName,
sidebarWidth, requestId,
}: SidebarItemProps) { workspaceId,
active,
sidebarWidth,
}: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const deleteRequest = useDeleteRequest(requestId); const deleteRequest = useDeleteRequest(requestId);
const updateRequest = useUpdateRequest(requestId); const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false); const [editing, setEditing] = useState<boolean>(false);
@@ -205,17 +267,60 @@ const SidebarItem = memo(function SidebarItem({
}, []); }, []);
const itemStyles = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); const itemStyles = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
},
[active],
);
if (workspaceId === null) return null; const handleInputKeyDown = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Enter':
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
setEditing(false);
break;
}
},
[active],
);
const actionItems = useMemo(
() => [
{
label: 'Delete Request',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
],
[],
);
return ( return (
<li className={classnames('block group/item px-2')} style={itemStyles}> <li
ref={ref}
className={classnames(className, 'block group/item px-2 pb-1')}
style={itemStyles}
>
<div className="relative w-full"> <div className="relative w-full">
<Button <Button
color="custom" color="custom"
size="sm" size="sm"
to={`/workspaces/${workspaceId}/requests/${requestId}`}
draggable={false} // Item should drag, not the link draggable={false} // Item should drag, not the link
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
onKeyDown={handleKeyDown}
className={classnames( className={classnames(
buttonClassName,
'w-full', 'w-full',
editing && 'focus-within:border-blue-400/40', editing && 'focus-within:border-blue-400/40',
active active
@@ -224,17 +329,6 @@ const SidebarItem = memo(function SidebarItem({
// Move out of the way when trash is shown // Move out of the way when trash is shown
'group-hover/item:pr-7', 'group-hover/item:pr-7',
)} )}
onKeyDown={(e) => {
// Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') {
e.preventDefault();
setEditing(true);
}
}}
to={`/workspaces/${workspaceId}/requests/${requestId}`}
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
> >
{editing ? ( {editing ? (
<input <input
@@ -242,16 +336,7 @@ const SidebarItem = memo(function SidebarItem({
defaultValue={requestName} defaultValue={requestName}
className="bg-transparent outline-none w-full" className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)} onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={async (e) => { onKeyDown={handleInputKeyDown}
switch (e.key) {
case 'Enter':
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
setEditing(false);
break;
}
}}
/> />
) : ( ) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}> <span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
@@ -259,18 +344,7 @@ const SidebarItem = memo(function SidebarItem({
</span> </span>
)} )}
</Button> </Button>
<Dropdown <Dropdown items={actionItems}>
items={useMemo(
() => [
{
label: 'Delete Request',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
],
[],
)}
>
<DropdownMenuTrigger <DropdownMenuTrigger
className={classnames( className={classnames(
'absolute right-0 top-0 transition-opacity opacity-0', 'absolute right-0 top-0 transition-opacity opacity-0',
@@ -290,13 +364,18 @@ const SidebarItem = memo(function SidebarItem({
</li> </li>
); );
}); });
const SidebarItem = memo(_SidebarItem);
type DraggableSidebarItemProps = SidebarItemProps & { type DraggableSidebarItemProps = SidebarItemProps & {
onMove: (id: string, hoverId: string) => void; onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onCancel: () => void;
}; };
type DragItem = { type DragItem = {
id: string; id: string;
workspaceId: string;
requestName: string;
}; };
const DraggableSidebarItem = memo(function DraggableSidebarItem({ const DraggableSidebarItem = memo(function DraggableSidebarItem({
@@ -306,37 +385,108 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({
active, active,
sidebarWidth, sidebarWidth,
onMove, onMove,
onEnd,
onCancel,
}: DraggableSidebarItemProps) { }: DraggableSidebarItemProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>({ const [, connectDrop] = useDrop<DragItem, void>({
accept: ItemTypes.REQUEST, accept: ItemTypes.REQUEST,
collect: (m) => ({ handlerId: m.getHandlerId(), isOver: m.isOver() }), collect: (m) => ({ handlerId: m.getHandlerId(), isOver: m.isOver() }),
hover: (item) => { hover: (item, monitor) => {
if (item.id !== requestId) { if (!ref.current) return;
onMove(requestId, item.id); 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 [{ isDragging }, connectDrag] = useDrag<DragItem, unknown, { isDragging: boolean }>(() => ({ const [{ isDragging }, connectDrag, preview] = useDrag<
type: ItemTypes.REQUEST, DragItem,
item: () => ({ id: requestId }), unknown,
collect: (m) => ({ isDragging: m.isDragging() }), { isDragging: boolean }
})); >(
() => ({
type: ItemTypes.REQUEST,
item: () => ({ id: requestId, requestName, workspaceId }),
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: (item, monitor) => {
if (monitor.didDrop()) {
onEnd(requestId);
} else {
onCancel();
}
},
}),
[onEnd],
);
// preview(getEmptyImage(), { captureDraggingState: true });
connectDrag(ref); connectDrag(ref);
connectDrop(ref); connectDrop(ref);
return ( return (
<div ref={ref} className={classnames(isDragging && 'opacity-0')}> <SidebarItem
<SidebarItem ref={ref}
requestName={requestName} className={classnames(isDragging && 'opacity-30')}
requestId={requestId} requestName={requestName}
workspaceId={workspaceId} requestId={requestId}
active={active} workspaceId={workspaceId}
sidebarWidth={sidebarWidth} active={active}
/> sidebarWidth={sidebarWidth}
</div> />
); );
}); });
function CustomDragLayer({ sidebarWidth }: { sidebarWidth: number }) {
const { itemType, isDragging, item, currentOffset } = useDragLayer<any, DragItem>((monitor) => ({
item: monitor.getItem(),
itemType: monitor.getItemType(),
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
}));
const styles = useMemo<CSSProperties>(() => {
if (currentOffset === null) {
return { display: 'none' };
}
const transform = `translate(${currentOffset.x}px, ${currentOffset.y}px)`;
return { transform, WebkitTransform: transform };
}, [currentOffset]);
if (!isDragging) {
return null;
}
return (
<div className="fixed !pointer-events-none inset-0">
<div className="absolute pointer-events-none" style={styles}>
{itemType === ItemTypes.REQUEST && (
<SidebarItem
buttonClassName="bg-violet-500/10"
sidebarWidth={sidebarWidth}
workspaceId={item.workspaceId}
requestName={item.requestName}
requestId={item.id}
/>
)}
</div>
</div>
);
}
const DropMarker = memo(
function DropMarker() {
return (
<div className="relative w-full h-0 overflow-visible pointer-events-none">
<div className="absolute z-20 left-0 right-0 bottom-[1px] h-[0.2em] bg-blue-300" />
</div>
);
},
() => true,
);

View File

@@ -26,6 +26,7 @@ export const UrlBar = memo(function UrlBar({ request }: Props) {
className="w-full flex items-center" className="w-full flex items-center"
> >
<Input <Input
key={request.id}
hideLabel hideLabel
useEditor={{ useEditor={{
useTemplating: true, useTemplating: true,

View File

@@ -1,13 +1,9 @@
import { DropdownMenuSeparator } from '@radix-ui/react-dropdown-menu';
import classnames from 'classnames'; import classnames from 'classnames';
import { useNavigate } from 'react-router-dom';
import { useWindowSize } from 'react-use'; import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useDeleteRequest } from '../hooks/useDeleteRequest'; import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useWorkspaces } from '../hooks/useWorkspaces'; import { Dropdown, DropdownMenuTrigger } from './core/Dropdown';
import { Button } from './core/Button';
import { Dropdown, DropdownMenuRadio, DropdownMenuTrigger } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
@@ -18,11 +14,9 @@ import { Sidebar } from './Sidebar';
import { WorkspaceDropdown } from './WorkspaceDropdown'; import { WorkspaceDropdown } from './WorkspaceDropdown';
export default function Workspace() { export default function Workspace() {
const navigate = useNavigate();
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace(); const activeWorkspace = useActiveWorkspace();
const deleteRequest = useDeleteRequest(activeRequest?.id ?? null); const deleteRequest = useDeleteRequest(activeRequest?.id ?? null);
const workspaces = useWorkspaces();
const { width } = useWindowSize(); const { width } = useWindowSize();
const isSideBySide = width > 900; const isSideBySide = width > 900;
if (activeWorkspace == null) { if (activeWorkspace == null) {
@@ -40,7 +34,7 @@ export default function Workspace() {
alignItems="center" alignItems="center"
> >
<div className="flex-1 -ml-2"> <div className="flex-1 -ml-2">
<WorkspaceDropdown /> <WorkspaceDropdown className="pointer-events-auto" />
</div> </div>
<div className="flex-[2] text-center text-gray-700 text-sm truncate"> <div className="flex-[2] text-center text-gray-700 text-sm truncate">
{activeRequest?.name} {activeRequest?.name}

View File

@@ -1,3 +1,4 @@
import classnames from 'classnames';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
@@ -8,7 +9,11 @@ import type { DropdownItem } from './core/Dropdown';
import { Dropdown, DropdownMenuTrigger } from './core/Dropdown'; import { Dropdown, DropdownMenuTrigger } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
export function WorkspaceDropdown() { type Props = {
className?: string;
};
export function WorkspaceDropdown({ className }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace(); const activeWorkspace = useActiveWorkspace();
@@ -37,7 +42,7 @@ export function WorkspaceDropdown() {
return ( return (
<Dropdown items={items}> <Dropdown items={items}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button size="sm" className="!px-2 truncate" forDropdown> <Button size="sm" className={classnames(className, '!px-2 truncate')} forDropdown>
{activeWorkspace?.name ?? 'Unknown'} {activeWorkspace?.name ?? 'Unknown'}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -43,7 +43,7 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
() => () =>
classnames( classnames(
className, className,
'outline-none pointer-events-auto', 'outline-none',
'border border-transparent focus-visible:border-blue-300', 'border border-transparent focus-visible:border-blue-300',
'rounded-md flex items-center', 'rounded-md flex items-center',
colorStyles[color || 'default'], colorStyles[color || 'default'],

View File

@@ -143,7 +143,7 @@ const _DropdownMenuContent = forwardRef<HTMLDivElement, D.DropdownMenuContentPro
const t = setTimeout(() => { const t = setTimeout(() => {
const windowBox = document.documentElement.getBoundingClientRect(); const windowBox = document.documentElement.getBoundingClientRect();
const menuBox = divRef.getBoundingClientRect(); const menuBox = divRef.getBoundingClientRect();
const styles = { maxHeight: windowBox.height - menuBox.top - 5 - 45 }; const styles = { maxHeight: windowBox.height - menuBox.top - 5 };
setStyles(styles); setStyles(styles);
}); });
return () => clearTimeout(t); return () => clearTimeout(t);

View File

@@ -9,8 +9,8 @@ interface Props {
export function ScrollArea({ children, className }: Props) { export function ScrollArea({ children, className }: Props) {
return ( return (
<S.Root className={classnames(className, 'group/scroll')} type="always"> <S.Root className={classnames(className, 'group/scroll overflow-hidden')} type="hover">
<S.Viewport>{children}</S.Viewport> <S.Viewport className="h-full w-full">{children}</S.Viewport>
<ScrollBar orientation="vertical" /> <ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" /> <ScrollBar orientation="horizontal" />
<S.Corner /> <S.Corner />
@@ -23,12 +23,12 @@ function ScrollBar({ orientation }: { orientation: 'vertical' | 'horizontal' })
<S.Scrollbar <S.Scrollbar
orientation={orientation} orientation={orientation}
className={classnames( className={classnames(
'flex bg-transparent rounded-full', 'scrollbar-track flex rounded-full',
orientation === 'vertical' && 'w-1.5', orientation === 'vertical' && 'w-1.5',
orientation === 'horizontal' && 'h-1.5 flex-col', orientation === 'horizontal' && 'h-1.5 flex-col',
)} )}
> >
<S.Thumb className="flex-1 bg-gray-100 group-hover/scroll:bg-gray-200 rounded-full" /> <S.Thumb className="scrollbar-thumb flex-1 rounded-full" />
</S.Scrollbar> </S.Scrollbar>
); );
} }

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames'; import classnames from 'classnames';
import type { ComponentType, ReactNode } from 'react'; import type { ComponentType, HTMLAttributes, ReactNode } from 'react';
const gapClasses = { const gapClasses = {
0: 'gap-0', 0: 'gap-0',
@@ -23,9 +23,9 @@ export function HStack({ className, space, children, ...props }: HStackProps) {
); );
} }
export interface VStackProps extends BaseStackProps { export type VStackProps = BaseStackProps & {
children: ReactNode; children: ReactNode;
} };
export function VStack({ className, space, children, ...props }: VStackProps) { export function VStack({ className, space, children, ...props }: VStackProps) {
return ( return (
@@ -38,14 +38,12 @@ export function VStack({ className, space, children, ...props }: VStackProps) {
); );
} }
interface BaseStackProps { type BaseStackProps = HTMLAttributes<HTMLElement> & {
as?: ComponentType | 'ul'; as?: ComponentType | 'ul';
space?: keyof typeof gapClasses; space?: keyof typeof gapClasses;
alignItems?: 'start' | 'center'; alignItems?: 'start' | 'center';
justifyContent?: 'start' | 'center' | 'end'; justifyContent?: 'start' | 'center' | 'end';
className?: string; };
children?: ReactNode;
}
function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) { function BaseStack({ className, alignItems, justifyContent, children, as }: BaseStackProps) {
const Component = as ?? 'div'; const Component = as ?? 'div';

View File

@@ -7,7 +7,8 @@ import { useActiveWorkspace } from './useActiveWorkspace';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) { export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspace = useActiveWorkspace(); const workspace = useActiveWorkspace();
const navigate = useNavigate(); const navigate = useNavigate();
return useMutation<string, unknown, Pick<HttpRequest, 'name'>>({
return useMutation<string, unknown, Pick<HttpRequest, 'name' | 'sortPriority'>>({
mutationFn: (patch) => { mutationFn: (patch) => {
if (workspace === null) { if (workspace === null) {
throw new Error("Cannot create request when there's no active workspace"); throw new Error("Cannot create request when there's no active workspace");

View File

@@ -0,0 +1,26 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useRequests } from './useRequests';
export function useUpdateAnyRequest() {
const requests = useRequests();
return useMutation<void, unknown, Partial<HttpRequest> & { id: string }>({
mutationFn: async (patch) => {
const request = requests.find((r) => r.id === patch.id) ?? null;
if (request === null) {
throw new Error("Can't update a null request");
}
const updatedRequest = { ...request, ...patch };
await invoke('update_request', {
request: {
...updatedRequest,
createdAt: updatedRequest.createdAt.toISOString().replace('Z', ''),
updatedAt: updatedRequest.updatedAt.toISOString().replace('Z', ''),
},
});
},
});
}

View File

@@ -18,6 +18,7 @@ export interface HttpHeader {
export interface HttpRequest extends BaseModel { export interface HttpRequest extends BaseModel {
readonly model: 'http_request'; readonly model: 'http_request';
sortPriority: number;
name: string; name: string;
url: string; url: string;
body: string | null; body: string | null;

View File

@@ -23,12 +23,28 @@
cursor: default; cursor: default;
} }
.destroy-pointer-event,
.destroy-pointer-event * {
pointer-events: none !important;
}
/* Style the scrollbars */ /* Style the scrollbars */
::-webkit-scrollbar-corner, ::-webkit-scrollbar-corner,
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply w-1.5 h-1.5 bg-gray-300/10; @apply w-1.5 h-1.5;
} }
.scrollbar-track,
::-webkit-scrollbar-corner,
::-webkit-scrollbar {
@apply bg-gray-300/10;
}
::-webkit-scrollbar-thumb {
@apply hover:bg-gray-300 rounded-full;
}
.scrollbar-thumb,
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-gray-200 hover:bg-gray-300 rounded-full; @apply bg-gray-200 hover:bg-gray-300 rounded-full;
} }