Rename workspace

This commit is contained in:
Gregory Schier
2023-04-09 12:23:41 -07:00
parent 1b6cfbac77
commit f66dcb9267
20 changed files with 275 additions and 37 deletions

View File

@@ -506,6 +506,16 @@
}, },
"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 "
}, },
"e0f41023d877d94b7609ce910a71bd89c4827a558654b8ae14d85e6ba86990cf": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "\n UPDATE workspaces SET (name, updated_at) =\n (?, CURRENT_TIMESTAMP) WHERE id = ?;\n "
},
"e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": { "e3ade0a69348d512e47e964bded9d7d890b92fdc1e01c6c22fa5e91f943639f2": {
"describe": { "describe": {
"columns": [ "columns": [

View File

@@ -362,6 +362,21 @@ async fn duplicate_request(
emit_and_return(&window, "updated_model", request) emit_and_return(&window, "updated_model", request)
} }
#[tauri::command]
async fn update_workspace(
workspace: models::Workspace,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
let updated_workspace = models::update_workspace(workspace, pool)
.await
.expect("Failed to update request");
emit_and_return(&window, "updated_model", updated_workspace)
}
#[tauri::command] #[tauri::command]
async fn update_request( async fn update_request(
request: models::HttpRequest, request: models::HttpRequest,
@@ -436,6 +451,17 @@ async fn get_request(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command]
async fn get_workspace(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::Workspace, String> {
let pool = &*db_instance.lock().await;
models::get_workspace(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
async fn responses( async fn responses(
request_id: &str, request_id: &str,
@@ -546,8 +572,10 @@ fn main() {
send_ephemeral_request, send_ephemeral_request,
duplicate_request, duplicate_request,
create_request, create_request,
get_workspace,
create_workspace, create_workspace,
delete_workspace, delete_workspace,
update_workspace,
update_request, update_request,
delete_request, delete_request,
responses, responses,

View File

@@ -423,6 +423,24 @@ pub async fn update_response_if_id(
return update_response(response, pool).await; return update_response(response, pool).await;
} }
pub async fn update_workspace(
workspace: Workspace,
pool: &Pool<Sqlite>,
) -> Result<Workspace, sqlx::Error> {
sqlx::query!(
r#"
UPDATE workspaces SET (name, updated_at) =
(?, CURRENT_TIMESTAMP) WHERE id = ?;
"#,
workspace.name,
workspace.id,
)
.execute(pool)
.await
.expect("Failed to update workspace");
get_workspace(&workspace.id, pool).await
}
pub async fn update_response( pub async fn update_response(
response: HttpResponse, response: HttpResponse,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,

View File

@@ -45,13 +45,20 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
return ( return (
<DialogContext.Provider value={state}> <DialogContext.Provider value={state}>
{children} {children}
{dialogs.map(({ id, render, ...props }) => ( {dialogs.map((props: DialogEntry) => (
<Dialog open key={id} onClose={() => actions.hide(id)} {...props}> <DialogInstance key={props.id} {...props} />
{render({ hide: () => actions.hide(id) })}
</Dialog>
))} ))}
</DialogContext.Provider> </DialogContext.Provider>
); );
}; };
function DialogInstance({ id, render, ...props }: DialogEntry) {
const { actions } = useContext(DialogContext);
return (
<Dialog open onClose={() => actions.hide(id)} {...props}>
{render({ hide: () => actions.hide(id) })}
</Dialog>
);
}
export const useDialog = () => useContext(DialogContext).actions; export const useDialog = () => useContext(DialogContext).actions;

View File

@@ -111,7 +111,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
{activeResponse && ( {activeResponse && (
<HStack alignItems="center" className="w-full"> <HStack alignItems="center" className="w-full">
<div className="whitespace-nowrap px-3"> <div className="whitespace-nowrap px-3">
<StatusTag response={activeResponse} /> <StatusTag showReason response={activeResponse} />
{activeResponse.elapsed > 0 && <>&nbsp;&bull;&nbsp;{activeResponse.elapsed}ms</>} {activeResponse.elapsed > 0 && <>&nbsp;&bull;&nbsp;{activeResponse.elapsed}ms</>}
{activeResponse.body.length > 0 && ( {activeResponse.body.length > 0 && (
<>&nbsp;&bull;&nbsp;{(activeResponse.body.length / 1000).toFixed(1)} KB</> <>&nbsp;&bull;&nbsp;{(activeResponse.body.length / 1000).toFixed(1)} KB</>
@@ -165,7 +165,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
</TabContent> </TabContent>
<TabContent value="body"> <TabContent value="body">
{!activeResponse.body ? ( {!activeResponse.body ? (
<EmptyStateText>No Response</EmptyStateText> <EmptyStateText>Empty Body</EmptyStateText>
) : viewMode === 'pretty' && contentType.includes('html') ? ( ) : viewMode === 'pretty' && contentType.includes('html') ? (
<Webview <Webview
body={activeResponse.body} body={activeResponse.body}

View File

@@ -3,12 +3,15 @@ import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace'; import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace'; import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { useRoutes } from '../hooks/useRoutes'; import { useRoutes } from '../hooks/useRoutes';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces'; import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown'; import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
type Props = { type Props = {
className?: string; className?: string;
@@ -19,7 +22,9 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
const activeWorkspace = useActiveWorkspace(); const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null; const activeWorkspaceId = activeWorkspace?.id ?? null;
const createWorkspace = useCreateWorkspace({ navigateAfter: true }); const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace); const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const prompt = usePrompt();
const routes = useRoutes(); const routes = useRoutes();
const items: DropdownItem[] = useMemo(() => { const items: DropdownItem[] = useMemo(() => {
@@ -32,31 +37,59 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
}, },
})); }));
const activeWorkspaceItems: DropdownItem[] =
workspaces.length <= 1
? []
: [
...workspaceItems,
{
type: 'separator',
label: activeWorkspace?.name,
},
];
return [ return [
...workspaceItems, ...activeWorkspaceItems,
{ {
type: 'separator', label: 'Rename',
label: 'Actions', leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Workspace',
description: (
<>
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: activeWorkspace?.name,
});
updateWorkspace.mutate({ name });
},
}, },
{ {
label: 'New Workspace', label: 'Delete',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
},
{
label: 'Delete Workspace',
leftSlot: <Icon icon="trash" />, leftSlot: <Icon icon="trash" />,
onSelect: deleteWorkspace.mutate, onSelect: deleteWorkspace.mutate,
variant: 'danger',
},
{ type: 'separator' },
{
label: 'Create Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({ name: 'Workspace' }),
}, },
]; ];
}, [ }, [
workspaces, workspaces,
deleteWorkspace.mutate,
activeWorkspaceId, activeWorkspaceId,
routes, routes,
createWorkspace, prompt,
confirm,
activeWorkspace?.name, activeWorkspace?.name,
deleteWorkspace, updateWorkspace,
createWorkspace,
]); ]);
return ( return (

View File

@@ -36,6 +36,7 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
children, children,
forDropdown, forDropdown,
color, color,
type = 'button',
justify = 'center', justify = 'center',
size = 'md', size = 'md',
...props ...props
@@ -68,7 +69,7 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
); );
} else { } else {
return ( return (
<button ref={ref} className={classes} {...props}> <button ref={ref} type={type} className={classes} {...props}>
{isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />} {isLoading && <Icon icon="update" size={size} className="animate-spin mr-1" />}
{children} {children}
{forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />} {forDropdown && <Icon icon="chevronDown" size={size} className="ml-1 -mr-1" />}

View File

@@ -76,7 +76,7 @@ export function Dialog({
{title} {title}
</Heading> </Heading>
{description && <p id={descriptionId}>{description}</p>} {description && <p id={descriptionId}>{description}</p>}
<div className="mt-6">{children}</div> <div className="mt-4">{children}</div>
</motion.div> </motion.div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,15 @@ import classnames from 'classnames';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react'; import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, {
Children,
cloneElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useKeyPressEvent } from 'react-use'; import { useKeyPressEvent } from 'react-use';
import { Portal } from '../Portal'; import { Portal } from '../Portal';
import { Separator } from './Separator'; import { Separator } from './Separator';
@@ -17,6 +25,7 @@ export type DropdownItem =
| { | {
type?: 'default'; type?: 'default';
label: string; label: string;
variant?: 'danger';
disabled?: boolean; disabled?: boolean;
hidden?: boolean; hidden?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
@@ -94,6 +103,17 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 }); setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
}, []); }, []);
// Close menu on space bar
const handleMenuKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === ' ') {
e.preventDefault();
onClose();
}
},
[onClose],
);
useKeyPressEvent('Escape', (e) => { useKeyPressEvent('Escape', (e) => {
e.preventDefault(); e.preventDefault();
onClose(); onClose();
@@ -181,6 +201,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
<div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} /> <div tabIndex={-1} aria-hidden className="fixed inset-0" onClick={onClose} />
<motion.div <motion.div
tabIndex={0} tabIndex={0}
onKeyDown={handleMenuKeyDown}
initial={{ opacity: 0, y: -5, scale: 0.98 }} initial={{ opacity: 0, y: -5, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
role="menu" role="menu"
@@ -268,6 +289,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
className, className,
'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap', 'min-w-[8rem] outline-none px-2 mx-1.5 h-7 flex items-center text-sm text-gray-700 whitespace-nowrap',
'focus:bg-highlight focus:text-gray-900 rounded', 'focus:bg-highlight focus:text-gray-900 rounded',
item.variant === 'danger' && 'text-red-600',
)} )}
{...props} {...props}
> >

View File

@@ -23,6 +23,7 @@ import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
MoonIcon, MoonIcon,
PaperPlaneIcon, PaperPlaneIcon,
Pencil2Icon,
PlusCircledIcon, PlusCircledIcon,
PlusIcon, PlusIcon,
QuestionMarkIcon, QuestionMarkIcon,
@@ -65,6 +66,7 @@ const icons = {
magnifyingGlass: MagnifyingGlassIcon, magnifyingGlass: MagnifyingGlassIcon,
moon: MoonIcon, moon: MoonIcon,
paperPlane: PaperPlaneIcon, paperPlane: PaperPlaneIcon,
pencil: Pencil2Icon,
plus: PlusIcon, plus: PlusIcon,
plusCircle: PlusCircledIcon, plusCircle: PlusCircledIcon,
question: QuestionMarkIcon, question: QuestionMarkIcon,

View File

@@ -2,11 +2,12 @@ import classnames from 'classnames';
import type { HttpResponse } from '../../lib/models'; import type { HttpResponse } from '../../lib/models';
interface Props { interface Props {
response: Pick<HttpResponse, 'status' | 'error'>; response: Pick<HttpResponse, 'status' | 'statusReason' | 'error'>;
className?: string; className?: string;
showReason?: boolean;
} }
export function StatusTag({ response, className }: Props) { export function StatusTag({ response, className, showReason }: Props) {
const { status, error } = response; const { status, error } = response;
const label = error ? 'ERR' : status; const label = error ? 'ERR' : status;
return ( return (
@@ -22,7 +23,7 @@ export function StatusTag({ response, className }: Props) {
status >= 500 && 'text-red-600', status >= 500 && 'text-red-600',
)} )}
> >
{label} {label} {showReason && response.statusReason && response.statusReason}
</span> </span>
); );
} }

View File

@@ -73,17 +73,17 @@ export function Tabs({
aria-label={label} aria-label={label}
className={classnames( className={classnames(
tabListClassName, tabListClassName,
'flex items-center overflow-x-auto hide-scrollbars mt-1 mb-2', 'flex items-center overflow-x-auto overflow-y-visible hide-scrollbars mt-1 mb-2',
// Give space for button focus states within overflow boundary. // Give space for button focus states within overflow boundary.
'px-2 -mx-2', '-mx-5 pl-3 py-1',
)} )}
> >
<HStack space={1} className="flex-shrink-0"> <HStack space={2} className="flex-shrink-0">
{tabs.map((t) => { {tabs.map((t) => {
const isActive = t.value === value; const isActive = t.value === value;
const btnClassName = classnames( const btnClassName = classnames(
isActive ? '' : 'text-gray-600 hover:text-gray-800', isActive ? '' : 'text-gray-600 hover:text-gray-800',
'!px-0 mr-4 ml-[1px]', '!px-2 ml-[1px]',
); );
if ('options' in t) { if ('options' in t) {

View File

@@ -3,7 +3,7 @@ import { Button } from '../components/core/Button';
import { HStack } from '../components/core/Stacks'; import { HStack } from '../components/core/Stacks';
export interface ConfirmProps { export interface ConfirmProps {
hide: () => void; onHide: () => void;
onResult: (result: boolean) => void; onResult: (result: boolean) => void;
variant?: 'delete' | 'confirm'; variant?: 'delete' | 'confirm';
} }
@@ -18,7 +18,7 @@ const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> =
confirm: 'Confirm', confirm: 'Confirm',
}; };
export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) { export function Confirm({ onHide, onResult, variant = 'confirm' }: ConfirmProps) {
const focusRef = (el: HTMLButtonElement | null) => { const focusRef = (el: HTMLButtonElement | null) => {
setTimeout(() => { setTimeout(() => {
el?.focus(); el?.focus();
@@ -27,16 +27,16 @@ export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) {
const handleHide = () => { const handleHide = () => {
onResult(false); onResult(false);
hide(); onHide();
}; };
const handleSuccess = () => { const handleSuccess = () => {
onResult(true); onResult(true);
hide(); onHide();
}; };
return ( return (
<HStack space={2} justifyContent="end"> <HStack space={2} justifyContent="end" className="mt-6">
<Button className="focus" color="gray" onClick={handleHide}> <Button className="focus" color="gray" onClick={handleHide}>
Cancel Cancel
</Button> </Button>

48
src-web/hooks/Prompt.tsx Normal file
View File

@@ -0,0 +1,48 @@
import type { FormEvent } from 'react';
import { useCallback, useState } from 'react';
import { Button } from '../components/core/Button';
import type { InputProps } from '../components/core/Input';
import { Input } from '../components/core/Input';
import { HStack, VStack } from '../components/core/Stacks';
export interface PromptProps {
onHide: () => void;
onResult: (value: string) => void;
label: InputProps['label'];
name: InputProps['name'];
defaultValue: InputProps['defaultValue'];
}
export function Prompt({ onHide, label, name, defaultValue, onResult }: PromptProps) {
const [value, setValue] = useState<string>(defaultValue ?? '');
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onHide();
onResult(value);
},
[onHide, onResult, value],
);
return (
<form onSubmit={handleSubmit}>
<VStack space={6}>
<Input
hideLabel
label={label}
name={name}
defaultValue={defaultValue}
onChange={setValue}
/>
<HStack space={2} justifyContent="end">
<Button className="focus" color="gray" onClick={onHide}>
Cancel
</Button>
<Button type="submit" className="focus" color="primary">
Save
</Button>
</HStack>
</VStack>
</form>
);
}

View File

@@ -20,7 +20,7 @@ export function useConfirm() {
description, description,
hideX: true, hideX: true,
size: 'sm', size: 'sm',
render: ({ hide }) => Confirm({ hide, variant, onResult }), render: ({ hide }) => Confirm({ onHide: hide, variant, onResult }),
}); });
}); });
} }

View File

@@ -19,7 +19,7 @@ export function useDeleteRequest(id: string | null) {
variant: 'delete', variant: 'delete',
description: ( description: (
<> <>
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>? Permanently delete <InlineCode>{request?.name}</InlineCode>?
</> </>
), ),
}); });

View File

@@ -21,7 +21,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
variant: 'delete', variant: 'delete',
description: ( description: (
<> <>
Are you sure you want to delete <InlineCode>{workspace?.name}</InlineCode>? Permanently delete <InlineCode>{workspace?.name}</InlineCode>?
</> </>
), ),
}); });

View File

@@ -0,0 +1,30 @@
import type { DialogProps } from '../components/core/Dialog';
import { useDialog } from '../components/DialogContext';
import type { PromptProps } from './Prompt';
import { Prompt } from './Prompt';
export function usePrompt() {
const dialog = useDialog();
return ({
title,
description,
name,
label,
defaultValue,
}: {
title: DialogProps['title'];
description?: DialogProps['description'];
name: PromptProps['name'];
label: PromptProps['label'];
defaultValue: PromptProps['defaultValue'];
}) =>
new Promise((onResult: PromptProps['onResult']) => {
dialog.show({
title,
description,
hideX: true,
size: 'sm',
render: ({ hide }) => Prompt({ onHide: hide, onResult, name, label, defaultValue }),
});
});
}

View File

@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { getWorkspace } from '../lib/store';
import { workspacesQueryKey } from './useWorkspaces';
export function useUpdateWorkspace(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, unknown, Partial<Workspace> | ((w: Workspace) => Workspace)>({
mutationFn: async (v) => {
const workspace = await getWorkspace(id);
if (workspace == null) {
throw new Error("Can't update a null workspace");
}
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
await invoke('update_workspace', { workspace: newWorkspace });
},
onMutate: async (v) => {
const workspace = await getWorkspace(id);
if (workspace === null) return;
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(workspace), (workspaces) =>
(workspaces ?? []).map((w) => (w.id === newWorkspace.id ? newWorkspace : w)),
);
},
});
}

View File

@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from './models'; import type { HttpRequest, Workspace } from './models';
export async function getRequest(id: string | null): Promise<HttpRequest | null> { export async function getRequest(id: string | null): Promise<HttpRequest | null> {
if (id === null) return null; if (id === null) return null;
@@ -9,3 +9,12 @@ export async function getRequest(id: string | null): Promise<HttpRequest | null>
} }
return request; return request;
} }
export async function getWorkspace(id: string | null): Promise<Workspace | null> {
if (id === null) return null;
const workspace: Workspace = (await invoke('get_workspace', { id })) ?? null;
if (workspace == null) {
return null;
}
return workspace;
}