Better model updates

This commit is contained in:
Gregory Schier
2023-03-31 13:21:02 -07:00
parent 946d7dc89e
commit 2836a28988
24 changed files with 273 additions and 215 deletions

View File

@@ -5,7 +5,7 @@ import { Dialog } from './core/Dialog';
type DialogEntry = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className'>;
} & Pick<DialogProps, 'title' | 'description' | 'hideX' | 'className' | 'size'>;
type DialogEntryOptionalId = Omit<DialogEntry, 'id'> & { id?: string };

View File

@@ -5,6 +5,7 @@ import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useRequest } from '../hooks/useRequest';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
interface Props {
requestId: string;
@@ -30,9 +31,12 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Request',
description: `Are you sure you want to delete "${request?.name}"?`,
confirmButtonColor: 'danger',
confirmButtonText: 'Delete',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{request?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteRequest.mutate();

View File

@@ -10,12 +10,13 @@ import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
type Props = {
className?: string;
};
export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }: Props) {
export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ className }: Props) {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
@@ -51,9 +52,12 @@ export const WorkspaceDropdown = memo(function WorkspaceDropdown({ className }:
onSelect: async () => {
const confirmed = await confirm({
title: 'Delete Workspace',
description: `Are you sure you want to delete "${activeWorkspace?.name}"?`,
confirmButtonColor: 'danger',
confirmButtonText: 'Delete',
variant: 'delete',
description: (
<>
Are you sure you want to delete <InlineCode>{activeWorkspace?.name}</InlineCode>?
</>
),
});
if (confirmed) {
deleteWorkspace.mutate();

View File

@@ -5,7 +5,7 @@ import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceDropdown } from './WorkspaceDropdown';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
interface Props {
className?: string;
@@ -21,13 +21,12 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
>
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions />
<WorkspaceDropdown className="pointer-events-auto" />
<WorkspaceActionsDropdown className="pointer-events-auto" />
</HStack>
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
{activeRequest?.name}
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
<IconButton size="sm" title="" icon="magnifyingGlass" />
{activeRequest && (
<RequestActionsDropdown requestId={activeRequest?.id}>
<IconButton

View File

@@ -3,8 +3,8 @@ import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import { VStack } from './Stacks';
export interface DialogProps {
children: ReactNode;
@@ -13,14 +13,14 @@ export interface DialogProps {
title: ReactNode;
description?: ReactNode;
className?: string;
wide?: boolean;
size?: 'sm' | 'md' | 'full' | 'dynamic';
hideX?: boolean;
}
export function Dialog({
children,
className,
wide,
size = 'full',
open,
onClose,
title,
@@ -48,9 +48,12 @@ export function Dialog({
className={classnames(
className,
'relative bg-gray-50 pointer-events-auto',
'w-[20rem] max-h-[80vh] p-5 rounded-lg overflow-auto',
'max-h-[80vh] p-5 rounded-lg overflow-auto',
'dark:border border-gray-200 shadow-md shadow-black/10',
wide && 'w-[80vw] max-w-[50rem]',
size === 'sm' && 'w-[25rem]',
size === 'md' && 'w-[45rem]',
size === 'full' && 'w-[80vw]',
size === 'dynamic' && 'min-w-[30vw] max-w-[80vw]',
)}
>
{!hideX && (
@@ -63,13 +66,11 @@ export function Dialog({
className="ml-auto absolute right-1 top-1"
/>
)}
<VStack space={3}>
<h1 className="text-xl font-semibold w-full" id={titleId}>
{title}
</h1>
{description && <p id={descriptionId}>{description}</p>}
<div>{children}</div>
</VStack>
<Heading className="text-xl font-semibold w-full" id={titleId}>
{title}
</Heading>
{description && <p id={descriptionId}>{description}</p>}
<div className="mt-6">{children}</div>
</motion.div>
</div>
</div>

View File

@@ -1,12 +1,7 @@
import classnames from 'classnames';
import type { ReactNode } from 'react';
import type { HTMLAttributes } from 'react';
type Props = {
className?: string;
children?: ReactNode;
};
export function Heading({ className, children, ...props }: Props) {
export function Heading({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h1 className={classnames(className, 'text-2xl font-semibold text-gray-900 mb-3')} {...props}>
{children}

View File

@@ -0,0 +1,14 @@
import classnames from 'classnames';
import type { HTMLAttributes } from 'react';
export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
return (
<code
className={classnames(
className,
'font-mono text-sm bg-highlight border-0 border-gray-200 px-1.5 py-0.5 rounded text-gray-800 shadow-inner',
)}
{...props}
/>
);
}

View File

@@ -2,20 +2,27 @@ import type { ButtonProps } from '../components/core/Button';
import { Button } from '../components/core/Button';
import { HStack } from '../components/core/Stacks';
interface Props {
export interface ConfirmProps {
hide: () => void;
onResult: (result: boolean) => void;
confirmButtonColor?: ButtonProps['color'];
confirmButtonText?: string;
variant?: 'delete' | 'confirm';
}
export function Confirm({
hide,
onResult,
confirmButtonColor = 'primary',
confirmButtonText = 'Confirm',
}: Props) {
const colors: Record<NonNullable<ConfirmProps['variant']>, ButtonProps['color']> = {
delete: 'danger',
confirm: 'primary',
};
const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> = {
delete: 'Delete',
confirm: 'Confirm',
};
export function Confirm({ hide, onResult, variant = 'confirm' }: ConfirmProps) {
const focusRef = (el: HTMLButtonElement | null) => {
el?.focus();
setTimeout(() => {
el?.focus();
});
};
const handleHide = () => {
@@ -33,8 +40,8 @@ export function Confirm({
<Button className="focus" color="gray" onClick={handleHide}>
Cancel
</Button>
<Button className="focus" ref={focusRef} color={confirmButtonColor} onClick={handleSuccess}>
{confirmButtonText}
<Button className="focus" ref={focusRef} color={colors[variant]} onClick={handleSuccess}>
{confirmButtonTexts[variant]}
</Button>
</HStack>
);

View File

@@ -0,0 +1,26 @@
import type { DialogProps } from '../components/core/Dialog';
import { useDialog } from '../components/DialogContext';
import type { ConfirmProps } from './Confirm';
import { Confirm } from './Confirm';
export function useConfirm() {
const dialog = useDialog();
return ({
title,
description,
variant,
}: {
title: DialogProps['title'];
description?: DialogProps['description'];
variant: ConfirmProps['variant'];
}) =>
new Promise((onResult: ConfirmProps['onResult']) => {
dialog.show({
title,
description,
hideX: true,
size: 'sm',
render: ({ hide }) => Confirm({ hide, variant, onResult }),
});
});
}

View File

@@ -1,34 +0,0 @@
import type { ButtonProps } from '../components/core/Button';
import { useDialog } from '../components/DialogContext';
import { Confirm } from './Confirm';
export function useConfirm() {
const dialog = useDialog();
return ({
title,
description,
confirmButtonColor,
confirmButtonText,
}: {
title: string;
description?: string;
confirmButtonColor?: ButtonProps['color'];
confirmButtonText?: string;
}) => {
return new Promise((resolve: (r: boolean) => void) => {
dialog.show({
title,
description,
hideX: true,
render: ({ hide }) => (
<Confirm
hide={hide}
onResult={resolve}
confirmButtonColor={confirmButtonColor}
confirmButtonText={confirmButtonText}
/>
),
});
});
};
}

View File

@@ -1,26 +1,31 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useRequests } from './useRequests';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { requestsQueryKey, useRequests } from './useRequests';
import { useRoutes } from './useRoutes';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspace = useActiveWorkspace();
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
const requests = useRequests();
const queryClient = useQueryClient();
return useMutation<string, unknown, Partial<Pick<HttpRequest, 'name' | 'sortPriority'>>>({
return useMutation<HttpRequest, unknown, Partial<Pick<HttpRequest, 'name' | 'sortPriority'>>>({
mutationFn: (patch) => {
if (workspace === null) {
if (workspaceId === null) {
throw new Error("Cannot create request when there's no active workspace");
}
const sortPriority = maxSortPriority(requests) + 1000;
return invoke('create_request', { sortPriority, workspaceId: workspace.id, ...patch });
return invoke('create_request', { sortPriority, workspaceId, ...patch });
},
onSuccess: async (requestId) => {
if (navigateAfter && workspace !== null) {
routes.navigate('request', { workspaceId: workspace.id, requestId });
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),
(requests) => [...(requests ?? []), request],
);
if (navigateAfter) {
routes.navigate('request', { workspaceId: request.workspaceId, requestId: request.id });
}
},
});

View File

@@ -1,17 +1,23 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { useRoutes } from './useRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useRoutes();
return useMutation<string, unknown, Pick<Workspace, 'name'>>({
const queryClient = useQueryClient();
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSuccess: async (workspaceId) => {
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),
workspace,
]);
if (navigateAfter) {
routes.navigate('workspace', { workspaceId });
routes.navigate('workspace', { workspaceId: workspace.id });
}
},
});

View File

@@ -1,19 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import type { HttpRequest } from '../lib/models';
import { useActiveRequestId } from './useActiveRequestId';
import { requestsQueryKey } from './useRequests';
import { responsesQueryKey } from './useResponses';
import { useRoutes } from './useRoutes';
export function useDeleteRequest(id: string | null) {
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (id === null) return;
await invoke('delete_request', { requestId: id });
},
onSuccess: async () => {
if (workspaceId === null || id === null) return;
await queryClient.invalidateQueries(requestsQueryKey({ workspaceId }));
const activeRequestId = useActiveRequestId();
const routes = useRoutes();
return useMutation<HttpRequest, string>({
mutationFn: async () => invoke('delete_request', { requestId: id }),
onSuccess: async ({ workspaceId, id: requestId }) => {
queryClient.setQueryData(responsesQueryKey({ requestId }), []); // Responses were deleted
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey({ workspaceId }), (requests) =>
(requests ?? []).filter((r) => r.id !== requestId),
);
if (activeRequestId === requestId) {
routes.navigate('workspace', { workspaceId });
}
},
});
}

View File

@@ -1,11 +1,18 @@
import { useMutation } from '@tanstack/react-query';
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(id: string | null) {
return useMutation({
const queryClient = useQueryClient();
return useMutation<HttpResponse>({
mutationFn: async () => {
if (id === null) return;
await invoke('delete_response', { id: id });
return await invoke('delete_response', { id: id });
},
onSuccess: ({ requestId, id: responseId }) => {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey({ requestId }), (responses) =>
(responses ?? []).filter((response) => response.id !== responseId),
);
},
});
}

View File

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

View File

@@ -1,6 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { requestsQueryKey } from './useRequests';
import { useRoutes } from './useRoutes';
import { workspacesQueryKey } from './useWorkspaces';
@@ -8,17 +10,21 @@ 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 });
return useMutation<Workspace, string>({
mutationFn: () => {
return invoke('delete_workspace', { id });
},
onSuccess: async () => {
if (id === null) return;
await queryClient.invalidateQueries(workspacesQueryKey());
if (id === activeWorkspaceId) {
onSuccess: async ({ id: workspaceId }) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) =>
workspaces?.filter((workspace) => workspace.id !== workspaceId),
);
if (workspaceId === activeWorkspaceId) {
routes.navigate('workspaces');
}
// Also clean up other things that may have been deleted
queryClient.setQueryData(requestsQueryKey({ workspaceId }), []);
await queryClient.invalidateQueries(requestsQueryKey({ workspaceId }));
},
});
}

View File

@@ -33,7 +33,7 @@ export function useKeyValue<T extends Object | null>({
const mutate = useMutation<void, unknown, T>({
mutationFn: (value) => setKeyValue<T>({ namespace, key, value }),
// k/v should be as fast as possible, so optimistically update the cache
onMutate: (value) => queryClient.setQueryData(keyValueQueryKey({ namespace, key }), value),
onMutate: (value) => queryClient.setQueryData<T>(keyValueQueryKey({ namespace, key }), value),
});
const set = useCallback(

View File

@@ -1,11 +1,19 @@
import { useMutation } from '@tanstack/react-query';
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 useSendRequest(id: string | null) {
return useMutation<void, string>({
const queryClient = useQueryClient();
return useMutation<HttpResponse, string>({
mutationFn: async () => {
if (id === null) return;
await invoke('send_request', { requestId: id });
return invoke('send_request', { requestId: id });
},
onSuccess: (response) => {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(response), (responses) => [
...(responses ?? []),
response,
]);
},
}).mutate;
}

View File

@@ -1,13 +1,14 @@
import { useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { EventCallback } from '@tauri-apps/api/event';
import { listen as tauriListen } from '@tauri-apps/api/event';
import { appWindow } from '@tauri-apps/api/window';
import { matchPath } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { debounce } from '../lib/debounce';
import { extractKeyValue, NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, KeyValue, Model, Workspace } from '../lib/models';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
import { keyValueQueryKey } from './useKeyValue';
import { requestsQueryKey } from './useRequests';
@@ -30,7 +31,7 @@ export function useTauriListeners() {
// eslint-disable-next-line @typescript-eslint/ban-types
function listen<T>(event: string, fn: EventCallback<T>) {
appWindow.listen(event, fn).then((unsub) => {
tauriListen(event, fn).then((unsub) => {
if (unmounted) unsub();
else unsubFns.push(unsub);
});
@@ -43,13 +44,10 @@ export function useTauriListeners() {
listen('toggle_sidebar', sidebarDisplay.toggle);
listen('refresh', () => location.reload());
listenDebounced('updated_key_value', ({ payload: keyValue }: { payload: KeyValue }) => {
if (keyValue.namespace !== NAMESPACE_NO_SYNC) {
queryClient.setQueryData(keyValueQueryKey(keyValue), extractKeyValue(keyValue));
}
});
listenDebounced<Model>('created_model', ({ payload, windowLabel }) => {
const cameFromSameWindow = windowLabel === appWindow.label;
if (cameFromSameWindow) return;
listenDebounced('created_model', ({ payload }: { payload: Model }) => {
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
@@ -71,11 +69,14 @@ export function useTauriListeners() {
const skipSync = payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC;
if (!skipSync) {
queryClient.setQueryData(queryKey, (values: Model[] = []) => [...values, payload]);
queryClient.setQueryData<Model[]>(queryKey, (values) => [...(values ?? []), payload]);
}
});
listenDebounced('updated_model', ({ payload }: { payload: Model }) => {
listenDebounced<Model>('updated_model', ({ payload, windowLabel }) => {
const cameFromSameWindow = windowLabel === appWindow.label;
if (cameFromSameWindow) return;
const queryKey =
payload.model === 'http_request'
? requestsQueryKey(payload)
@@ -94,26 +95,30 @@ export function useTauriListeners() {
const skipSync = payload.model === 'key_value' && payload.namespace === NAMESPACE_NO_SYNC;
if (payload.model === 'http_request') {
wasUpdatedExternally(payload.id);
}
if (!skipSync) {
queryClient.setQueryData(queryKey, (values: Model[] = []) =>
values.map((v) => (modelsEq(v, payload) ? payload : v)),
queryClient.setQueryData<Model[]>(queryKey, (values) =>
values?.map((v) => (modelsEq(v, payload) ? payload : v)),
);
}
});
listen('deleted_model', ({ payload: model }: { payload: Model }) => {
listen<Model>('deleted_model', ({ payload }) => {
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), removeById<HttpRequest>(model));
} else if (model.model === 'http_response') {
queryClient.setQueryData(responsesQueryKey(model), removeById<HttpResponse>(model));
} else if (model.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(model), undefined);
if (payload.model === 'workspace') {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(), removeById(payload));
} else if (payload.model === 'http_request') {
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(payload), removeById(payload));
} else if (payload.model === 'http_response') {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
} else if (payload.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(payload), undefined);
}
});

View File

@@ -19,8 +19,8 @@ export function useUpdateAnyRequest() {
onMutate: async ({ id, update }) => {
const request = await getRequest(id);
if (request === null) return;
queryClient.setQueryData(requestsQueryKey(request), (requests: HttpRequest[] | undefined) =>
requests?.map((r) => (r.id === request.id ? update(r) : r)),
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(request), (requests) =>
(requests ?? []).map((r) => (r.id === request.id ? update(r) : r)),
);
},
});

View File

@@ -21,8 +21,8 @@ export function useUpdateRequest(id: string | null) {
if (request === null) return;
const newRequest = typeof v === 'function' ? v(request) : { ...request, ...v };
queryClient.setQueryData(requestsQueryKey(request), (requests: HttpRequest[] | undefined) =>
requests?.map((r) => (r.id === newRequest.id ? newRequest : r)),
queryClient.setQueryData<HttpRequest[]>(requestsQueryKey(request), (requests) =>
(requests ?? []).map((r) => (r.id === newRequest.id ? newRequest : r)),
);
},
});

View File

@@ -1,11 +1,11 @@
import { getKeyValue, setKeyValue } from './keyValueStore';
import { getKeyValue, NAMESPACE_NO_SYNC, setKeyValue } from './keyValueStore';
export async function getLastLocation(): Promise<string> {
return getKeyValue({ key: 'last_location', fallback: '/' });
return getKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', fallback: '/' });
}
export async function setLastLocation(pathname: string): Promise<void> {
return setKeyValue({ key: 'last_location', value: pathname });
return setKeyValue({ namespace: NAMESPACE_NO_SYNC, key: 'last_location', value: pathname });
}
export async function syncLastLocation(): Promise<void> {