Typesafe routing and CM line height issue

This commit is contained in:
Gregory Schier
2023-03-20 16:47:36 -07:00
parent d310272d19
commit dc97b91a4e
20 changed files with 202 additions and 77 deletions

View File

@@ -313,14 +313,13 @@ async fn delete_request(
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<models::HttpRequest, String> {
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
let req = models::delete_request(request_id, pool)
.await
.expect("Failed to delete request");
app_handle.emit_all("deleted_request", request_id).unwrap();
Ok(req)
app_handle.emit_all("deleted_model", req).unwrap();
Ok(())
}
#[tauri::command]
@@ -357,12 +356,15 @@ async fn responses(
#[tauri::command]
async fn delete_response(
id: &str,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
models::delete_response(id, pool)
let response = models::delete_response(id, pool)
.await
.map_err(|e| e.to_string())
.expect("Failed to delete response");
app_handle.emit_all("deleted_model", response).unwrap();
Ok(())
}
#[tauri::command]
@@ -394,6 +396,20 @@ async fn workspaces(
}
}
#[tauri::command]
async fn delete_workspace(
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
id: &str,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
let workspace = models::delete_workspace(id, pool)
.await
.expect("Failed to delete workspace");
app_handle.emit_all("deleted_model", workspace).unwrap();
Ok(())
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
@@ -511,6 +527,7 @@ fn main() {
send_request,
create_request,
create_workspace,
delete_workspace,
update_request,
delete_request,
responses,

View File

@@ -142,6 +142,22 @@ pub async fn get_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, s
.await
}
pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace, sqlx::Error> {
let workspace = get_workspace(id, pool)
.await
.expect("Failed to get request to delete");
let _ = sqlx::query!(
r#"
DELETE FROM http_requests
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(workspace)
}
pub async fn create_workspace(
name: &str,
description: &str,
@@ -395,7 +411,11 @@ pub async fn find_responses(
.await
}
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> {
pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<HttpResponse, sqlx::Error> {
let resp = get_response(id, pool)
.await
.expect("Failed to get response to delete");
let _ = sqlx::query!(
r#"
DELETE FROM http_responses
@@ -406,7 +426,7 @@ pub async fn delete_response(id: &str, pool: &Pool<Sqlite>) -> Result<(), sqlx::
.execute(pool)
.await;
Ok(())
Ok(resp)
}
pub async fn delete_all_responses(

View File

@@ -10,12 +10,13 @@ import { matchPath } from 'react-router-dom';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { requestsQueryKey } from '../hooks/useRequests';
import { responsesQueryKey } from '../hooks/useResponses';
import { routePaths } from '../hooks/useRoutes';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { extractKeyValue } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models';
import { convertDates } from '../lib/models';
import { AppRouter, WORKSPACE_REQUEST_PATH } from './AppRouter';
import { AppRouter } from './AppRouter';
const queryClient = new QueryClient();
@@ -45,12 +46,6 @@ await listen('updated_request', ({ payload: request }: { payload: HttpRequest })
);
});
await listen('deleted_request', ({ payload: request }: { payload: HttpRequest }) => {
queryClient.setQueryData(requestsQueryKey(request.workspaceId), (requests: HttpRequest[] = []) =>
requests.filter((r) => r.id !== request.id),
);
});
await listen('updated_response', ({ payload: response }: { payload: HttpResponse }) => {
queryClient.setQueryData(
responsesQueryKey(response.requestId),
@@ -92,8 +87,27 @@ await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace
});
});
await listen(
'deleted_model',
({ payload: model }: { payload: Workspace | HttpRequest | HttpResponse | KeyValue }) => {
function removeById<T extends { id: string }>(model: T) {
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id);
}
if (model.model === 'workspace') {
queryClient.setQueryData(workspacesQueryKey(), removeById<Workspace>(model));
} else if (model.model === 'http_request') {
queryClient.setQueryData(requestsQueryKey(model.workspaceId), removeById<HttpRequest>(model));
} else if (model.model === 'http_response') {
queryClient.setQueryData(responsesQueryKey(model.requestId), removeById<HttpResponse>(model));
} else if (model.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(model), undefined);
}
},
);
await listen('send_request', async () => {
const params = matchPath(WORKSPACE_REQUEST_PATH, window.location.pathname);
const params = matchPath(routePaths.request(), window.location.pathname);
const requestId = params?.params.requestId;
if (typeof requestId !== 'string') {
return;

View File

@@ -1,13 +1,11 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom';
import { routePaths } from '../hooks/useRoutes';
const Workspaces = lazy(() => import('./Workspaces'));
const Workspace = lazy(() => import('./Workspace'));
const RouteError = lazy(() => import('./RouteError'));
export const WORKSPACE_PATH = '/workspaces/:workspaceId';
export const WORKSPACE_REQUEST_PATH = '/workspaces/:workspaceId/requests/:requestId';
const router = createBrowserRouter([
{
path: '/',
@@ -15,14 +13,21 @@ const router = createBrowserRouter([
children: [
{
path: '/',
element: <Navigate to={routePaths.workspaces()} replace={true} />,
},
{
path: routePaths.workspaces(),
element: <Workspaces />,
},
{
path: WORKSPACE_PATH,
path: routePaths.workspace({ workspaceId: ':workspaceId' }),
element: <Workspace />,
},
{
path: WORKSPACE_REQUEST_PATH,
path: routePaths.request({
workspaceId: ':workspaceId',
requestId: ':requestId',
}),
element: <Workspace />,
},
],

View File

@@ -2,7 +2,7 @@ import classnames from 'classnames';
import { memo, useEffect, useMemo, useState } from 'react';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useDeleteResponses } from '../hooks/useDeleteResponses';
import { useDeleteResponse } from '../hooks/useResponseDelete';
import { useDeleteResponse } from '../hooks/useDeleteResponse';
import { useResponses } from '../hooks/useResponses';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { tryFormatJson } from '../lib/formatters';
@@ -21,18 +21,18 @@ interface Props {
}
export const ResponsePane = memo(function ResponsePane({ className }: Props) {
const [activeResponseId, setActiveResponseId] = useState<string | null>(null);
const [pinnedResponseId, setPinnedResponseId] = useState<string | null>(null);
const activeRequestId = useActiveRequestId();
const responses = useResponses(activeRequestId);
const activeResponse: HttpResponse | null = activeResponseId
? responses.find((r) => r.id === activeResponseId) ?? null
const activeResponse: HttpResponse | null = pinnedResponseId
? responses.find((r) => r.id === pinnedResponseId) ?? null
: responses[responses.length - 1] ?? null;
const [viewMode, toggleViewMode] = useResponseViewMode(activeResponse?.requestId);
const deleteResponse = useDeleteResponse(activeResponse);
const deleteResponse = useDeleteResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteResponses(activeResponse?.requestId);
useEffect(() => {
setActiveResponseId(null);
setPinnedResponseId(null);
}, [responses.length]);
const contentType = useMemo(
@@ -92,7 +92,7 @@ export const ResponsePane = memo(function ResponsePane({ className }: Props) {
...responses.slice(0, 10).map((r) => ({
label: r.status + ' - ' + r.elapsed + ' ms',
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <></>,
onSelect: () => setActiveResponseId(r.id),
onSelect: () => setPinnedResponseId(r.id),
})),
]}
>

View File

@@ -29,7 +29,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
);
return (
<form onSubmit={handleSubmit} className={classnames(className, 'w-full flex items-center')}>
<form onSubmit={handleSubmit} className={className}>
<Input
key={requestId}
hideLabel

View File

@@ -1,9 +1,10 @@
import classnames from 'classnames';
import { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useRoutes } from '../hooks/useRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
@@ -15,11 +16,12 @@ type Props = {
};
export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }: Props) {
const navigate = useNavigate();
const routes = useRoutes();
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = useActiveWorkspaceId();
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
const deleteWorkspace = useDeleteWorkspace(activeWorkspaceId);
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({
@@ -27,7 +29,7 @@ export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }:
leftSlot: activeWorkspaceId === w.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => {
if (w.id === activeWorkspaceId) return;
navigate(`/workspaces/${w.id}`);
routes.navigate('workspace', { workspaceId: w.id });
},
}));
@@ -36,10 +38,14 @@ export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }:
'-----',
{
label: 'New Workspace',
value: 'new',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({ name: 'New Workspace' }),
},
{
label: 'Delete Workspace',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteWorkspace.mutate(),
},
];
}, [workspaces, activeWorkspaceId]);

View File

@@ -20,10 +20,9 @@ export type DropdownItem =
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
items: DropdownItem[];
ignoreClick?: boolean;
}
export function Dropdown({ children, items, ignoreClick }: DropdownProps) {
export function Dropdown({ children, items }: DropdownProps) {
const [open, setOpen] = useState<boolean>(false);
const ref = useRef<HTMLButtonElement>(null);
const child = useMemo(() => {
@@ -36,7 +35,6 @@ export function Dropdown({ children, items, ignoreClick }: DropdownProps) {
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
console.log('CLICK INSIDE');
e.preventDefault();
e.stopPropagation();
setOpen((o) => !o);

View File

@@ -24,6 +24,11 @@
@apply text-placeholder;
}
.cm-scroller {
/* Inherit line-height from outside */
line-height: inherit;
}
/* Don't show selection on blurred input */
.cm-selectionBackground {
@apply bg-transparent;
@@ -54,17 +59,15 @@
&.cm-singleline {
.cm-editor {
@apply h-full w-full;
@apply w-full h-auto;
}
.cm-scroller {
@apply font-mono flex text-[0.8rem];
align-items: center !important;
overflow: hidden !important;
@apply font-mono text-[0.8rem] overflow-hidden;
}
.cm-line {
@apply px-0;
@apply px-2 overflow-hidden;
}
}
@@ -96,11 +99,12 @@
}
.cm-editor .cm-gutterElement {
@apply flex items-center;
transition: color var(--transition-duration);
}
.cm-editor .fold-gutter-icon {
@apply pt-[0.3em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
@apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 cursor-pointer rounded;
}
.cm-editor .fold-gutter-icon::after {
@@ -109,7 +113,7 @@
}
.cm-editor .fold-gutter-icon[data-open] {
@apply pt-[0.4em] pl-[0.3em];
@apply pt-[0.38em] pl-[0.3em];
}
.cm-editor .fold-gutter-icon[data-open]::after {

View File

@@ -46,7 +46,7 @@ export function Input({
const id = `input-${name}`;
const inputClassName = classnames(
className,
'!bg-transparent pl-3 pr-2 min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
'!bg-transparent min-w-0 h-full w-full focus:outline-none placeholder:text-placeholder',
!!leftSlot && '!pl-0.5',
!!rightSlot && '!pr-0.5',
);
@@ -81,8 +81,8 @@ export function Input({
'relative w-full rounded-md text-gray-900',
'border border-gray-200 focus-within:border-focus',
!isValid && '!border-invalid',
size === 'md' && 'h-md',
size === 'sm' && 'h-sm',
size === 'md' && 'h-md leading-md',
size === 'sm' && 'h-sm leading-sm',
)}
>
{leftSlot}

View File

@@ -1,6 +1,7 @@
import { useParams } from 'react-router-dom';
import type { RouteParamsRequest } from './useRoutes';
export function useActiveRequestId(): string | null {
const { requestId } = useParams<{ requestId?: string }>();
const { requestId } = useParams<RouteParamsRequest>();
return requestId ?? null;
}

View File

@@ -1,6 +1,7 @@
import { useParams } from 'react-router-dom';
import type { RouteParamsWorkspace } from './useRoutes';
export function useActiveWorkspaceId(): string | null {
const { workspaceId } = useParams<{ workspaceId?: string }>();
const { workspaceId } = useParams<RouteParamsWorkspace>();
return workspaceId ?? null;
}

View File

@@ -1,17 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useNavigate } from 'react-router-dom';
import type { Workspace } from '../lib/models';
import { useRoutes } from './useRoutes';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const navigate = useNavigate();
const routes = useRoutes();
return useMutation<string, unknown, Pick<Workspace, 'name'>>({
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSuccess: async (workspaceId) => {
if (navigateAfter) {
navigate(`/workspaces/${workspaceId}`);
routes.navigate('workspace', { workspaceId });
}
},
});

View File

@@ -0,0 +1,11 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
export function useDeleteResponse(id: string | null) {
return useMutation({
mutationFn: async () => {
if (id === null) return;
await invoke('delete_response', { id: id });
},
});
}

View File

@@ -9,9 +9,9 @@ export function useDeleteResponses(requestId?: string) {
if (!requestId) return;
await invoke('delete_all_responses', { requestId });
},
onSuccess: () => {
onSuccess: async () => {
if (!requestId) return;
queryClient.setQueryData(responsesQueryKey(requestId), []);
await queryClient.invalidateQueries(responsesQueryKey(requestId));
},
});
}

View File

@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useNavigate } from 'react-router-dom';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useRoutes } from './useRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useDeleteWorkspace(id: string | null) {
const queryClient = useQueryClient();
const activeWorkspaceId = useActiveWorkspaceId();
const routes = useRoutes();
return useMutation<void, string>({
mutationFn: async () => {
if (id === null) return;
await invoke('delete_workspace', { id });
},
onSuccess: async () => {
if (id === null) return;
await queryClient.invalidateQueries(workspacesQueryKey());
if (id === activeWorkspaceId) {
routes.navigate('workspace', { workspaceId: id });
}
},
});
}

View File

@@ -1,21 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models';
import { responsesQueryKey } from './useResponses';
export function useDeleteResponse(response: HttpResponse | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (response === null) return;
await invoke('delete_response', { id: response.id });
},
onSuccess: () => {
if (response === null) return;
queryClient.setQueryData(
responsesQueryKey(response.requestId),
(responses: HttpResponse[] = []) => responses.filter((r) => r.id !== response.id),
);
},
});
}

View File

@@ -0,0 +1,39 @@
export type RouteParamsWorkspace = {
workspaceId: string;
};
export type RouteParamsRequest = RouteParamsWorkspace & {
requestId: string;
};
export const routePaths = {
workspaces() {
return '/workspaces';
},
workspace({ workspaceId } = { workspaceId: ':workspaceId' } as RouteParamsWorkspace) {
return `/workspaces/${workspaceId}`;
},
request(
{ workspaceId, requestId } = {
workspaceId: ':workspaceId',
requestId: ':requestId',
} as RouteParamsRequest,
) {
return `${this.workspace({ workspaceId })}/requests/${requestId}`;
},
};
export function useRoutes() {
return {
navigate<T extends keyof typeof routePaths>(
path: T,
params: Parameters<(typeof routePaths)[T]>[0],
) {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
routePaths[path](params as any);
},
paths: routePaths,
};
}

View File

@@ -1,6 +1,5 @@
export interface BaseModel {
readonly id: string;
readonly workspaceId: string;
readonly createdAt: Date;
readonly updatedAt: Date;
}
@@ -18,6 +17,7 @@ export interface HttpHeader {
}
export interface HttpRequest extends BaseModel {
readonly workspaceId: string;
readonly model: 'http_request';
sortPriority: number;
name: string;
@@ -36,6 +36,7 @@ export interface KeyValue extends Omit<BaseModel, 'id'> {
}
export interface HttpResponse extends BaseModel {
readonly workspaceId: string;
readonly model: 'http_response';
readonly requestId: string;
readonly body: string;

View File

@@ -13,7 +13,11 @@ module.exports = {
height: {
'sm': '2rem',
'md': '2.5rem',
}
},
lineHeight: {
'sm': '2rem',
'md': '2.5rem',
},
},
fontFamily: {
"mono": ["JetBrains Mono", "Menlo", "monospace"],