Persist sort priority!

This commit is contained in:
Gregory Schier
2023-03-19 00:48:09 -07:00
parent 9ac5572094
commit b586318d4b
17 changed files with 457 additions and 243 deletions
Binary file not shown.
@@ -0,0 +1 @@
ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;
+103 -91
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 "
} }
} }
+3 -1
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
+10 -3
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 = ?
+244 -94
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>
<div
ref={sidebarRef}
style={sidebarStyles}
className={classnames(
className,
'bg-gray-100 h-full border-r border-gray-200 relative grid grid-rows-[auto_minmax(0,1fr)_auto]',
)}
>
<HStack as={WindowDragRegion} alignItems="center" justifyContent="end"> <HStack as={WindowDragRegion} alignItems="center" justifyContent="end">
<IconButton <IconButton
title="Add Request" title="Add Request"
className="mx-1" className="mx-1"
icon="plusCircle" icon="plusCircle"
onClick={async () => { onClick={async () => {
await createRequest.mutate({ name: 'Test Request' }); const lastRequest = requests[requests.length - 1];
await createRequest.mutate({
name: 'Test Request',
sortPriority: lastRequest?.sortPriority ?? 0 + 1,
});
}} }}
/> />
</HStack> </HStack>
<VStack as="ul" className="py-3 overflow-auto h-full" space={1}> <ScrollArea>
<VStack as="ul" className="relative py-3" draggable={false}>
<SidebarItems <SidebarItems
sidebarWidth={sidebarWidth} sidebarWidth={sidebarWidth}
activeRequestId={activeRequest?.id} activeRequestId={activeRequest?.id}
requests={requests} requests={requests}
/> />
{/*<CustomDragLayer sidebarWidth={sidebarWidth} />*/}
</VStack> </VStack>
</ScrollArea>
<HStack className="mx-1 pb-1" alignItems="center" justifyContent="end"> <HStack className="mx-1 pb-1" alignItems="center" justifyContent="end">
<ToggleThemeButton /> <ToggleThemeButton />
</HStack> </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) => {
return (
<Fragment key={r.id}>
{hoveredIndex === i && <DropMarker />}
<DraggableSidebarItem <DraggableSidebarItem
key={request.id} key={r.id}
requestId={request.id} requestId={r.id}
requestName={request.name} requestName={r.name}
workspaceId={request.workspaceId} workspaceId={r.workspaceId}
active={request.id === activeRequestId} active={r.id === activeRequestId}
sidebarWidth={sidebarWidth} sidebarWidth={sidebarWidth}
onMove={handleMove} 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(
{
className,
buttonClassName,
requestName, requestName,
requestId, requestId,
workspaceId, workspaceId,
active, active,
sidebarWidth, sidebarWidth,
}: SidebarItemProps) { }: 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,44 +267,19 @@ const SidebarItem = memo(function SidebarItem({
}, []); }, []);
const itemStyles = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); const itemStyles = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
const handleKeyDown = useCallback(
if (workspaceId === null) return null; (e: KeyboardEvent<HTMLElement>) => {
return (
<li className={classnames('block group/item px-2')} style={itemStyles}>
<div className="relative w-full">
<Button
color="custom"
size="sm"
draggable={false} // Item should drag, not the link
className={classnames(
'w-full',
editing && 'focus-within:border-blue-400/40',
active
? 'bg-gray-200/70 text-gray-900'
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-gray-200/30',
// Move out of the way when trash is shown
'group-hover/item:pr-7',
)}
onKeyDown={(e) => {
// Hitting enter on active request during keyboard nav will start edit // Hitting enter on active request during keyboard nav will start edit
if (active && e.key === 'Enter') { if (active && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
setEditing(true); setEditing(true);
} }
}} },
to={`/workspaces/${workspaceId}/requests/${requestId}`} [active],
onDoubleClick={() => setEditing(true)} );
onClick={active ? () => setEditing(true) : undefined}
justify="start" const handleInputKeyDown = useCallback(
> async (e: KeyboardEvent<HTMLInputElement>) => {
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={async (e) => {
switch (e.key) { switch (e.key) {
case 'Enter': case 'Enter':
await handleSubmitNameEdit(e.currentTarget); await handleSubmitNameEdit(e.currentTarget);
@@ -251,16 +288,11 @@ const SidebarItem = memo(function SidebarItem({
setEditing(false); setEditing(false);
break; break;
} }
}} },
/> [active],
) : ( );
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'} const actionItems = useMemo(
</span>
)}
</Button>
<Dropdown
items={useMemo(
() => [ () => [
{ {
label: 'Delete Request', label: 'Delete Request',
@@ -269,8 +301,50 @@ const SidebarItem = memo(function SidebarItem({
}, },
], ],
[], [],
);
return (
<li
ref={ref}
className={classnames(className, 'block group/item px-2 pb-1')}
style={itemStyles}
>
<div className="relative w-full">
<Button
color="custom"
size="sm"
to={`/workspaces/${workspaceId}/requests/${requestId}`}
draggable={false} // Item should drag, not the link
onDoubleClick={() => setEditing(true)}
onClick={active ? () => setEditing(true) : undefined}
justify="start"
onKeyDown={handleKeyDown}
className={classnames(
buttonClassName,
'w-full',
editing && 'focus-within:border-blue-400/40',
active
? 'bg-gray-200/70 text-gray-900'
: 'text-gray-600 group-hover/item:text-gray-800 active:bg-gray-200/30',
// Move out of the way when trash is shown
'group-hover/item:pr-7',
)} )}
> >
{editing ? (
<input
ref={handleFocus}
defaultValue={requestName}
className="bg-transparent outline-none w-full"
onBlur={(e) => handleSubmitNameEdit(e.currentTarget)}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className={classnames('truncate', !requestName && 'text-gray-400 italic')}>
{requestName || 'New Request'}
</span>
)}
</Button>
<Dropdown items={actionItems}>
<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<
DragItem,
unknown,
{ isDragging: boolean }
>(
() => ({
type: ItemTypes.REQUEST, type: ItemTypes.REQUEST,
item: () => ({ id: requestId }), item: () => ({ id: requestId, requestName, workspaceId }),
collect: (m) => ({ isDragging: m.isDragging() }), 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}
className={classnames(isDragging && 'opacity-30')}
requestName={requestName} requestName={requestName}
requestId={requestId} requestId={requestId}
workspaceId={workspaceId} workspaceId={workspaceId}
active={active} active={active}
sidebarWidth={sidebarWidth} 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,
);
+1
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,
+2 -8
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}
+7 -2
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>
+1 -1
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'],
+1 -1
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);
+4 -4
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>
); );
} }
+5 -7
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';
+2 -1
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");
+26
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', ''),
},
});
},
});
}
+1
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;
+17 -1
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;
} }