Good start to multi-window

This commit is contained in:
Gregory Schier
2023-03-28 18:29:40 -07:00
parent 4f501abb72
commit 4c22215ca5
20 changed files with 771 additions and 517 deletions

View File

@@ -1,6 +1,5 @@
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { invoke } from '@tauri-apps/api';
import { listen } from '@tauri-apps/api/event';
@@ -21,6 +20,7 @@ import { extractKeyValue, getKeyValue, setKeyValue } from '../lib/keyValueStore'
import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models';
import { AppRouter } from './AppRouter';
import { DialogProvider } from './DialogContext';
import { appWindow, WebviewWindow } from '@tauri-apps/api/window';
const queryClient = new QueryClient({
defaultOptions: {
@@ -43,10 +43,13 @@ persistQueryClient({
});
await listen('updated_key_value', ({ payload: keyValue }: { payload: KeyValue }) => {
if (keyValue.updatedBy === appWindow.label) return;
queryClient.setQueryData(keyValueQueryKey(keyValue), extractKeyValue(keyValue));
});
await listen('updated_request', ({ payload: request }: { payload: HttpRequest }) => {
if (request.updatedBy === appWindow.label) return;
queryClient.setQueryData(
requestsQueryKey(request.workspaceId),
(requests: HttpRequest[] = []) => {
@@ -72,6 +75,8 @@ await listen('updated_response', ({ payload: response }: { payload: HttpResponse
queryClient.setQueryData(
responsesQueryKey(response.requestId),
(responses: HttpResponse[] = []) => {
if (response.updatedBy === appWindow.label) return;
const newResponses = [];
let found = false;
for (const r of responses) {
@@ -92,6 +97,8 @@ await listen('updated_response', ({ payload: response }: { payload: HttpResponse
await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace }) => {
queryClient.setQueryData(workspacesQueryKey(), (workspaces: Workspace[] = []) => {
if (workspace.updatedBy === appWindow.label) return;
const newWorkspaces = [];
let found = false;
for (const w of workspaces) {
@@ -175,7 +182,7 @@ export function App() {
<DndProvider backend={HTML5Backend}>
<DialogProvider>
<AppRouter />
<ReactQueryDevtools initialIsOpen={false} />
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
</DialogProvider>
</DndProvider>
</HelmetProvider>

View File

@@ -62,9 +62,14 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
});
const req: HttpRequest = { ...baseRequest, body, id: '' };
sendEphemeralRequest(req).then((response) => {
const { data } = JSON.parse(response.body);
const schema = buildClientSchema(data);
setGraphqlExtension(graphql(schema, {}));
try {
const { data } = JSON.parse(response.body);
const schema = buildClientSchema(data);
setGraphqlExtension(graphql(schema, {}));
} catch (err) {
console.log('Failed to parse introspection query', err);
return;
}
});
}, [baseRequest.url]);

View File

@@ -35,6 +35,8 @@ export function Overlay({ zIndex = 30, open, onClose, portalName, children }: Pr
onClick={onClose}
className="absolute inset-0 bg-gray-600/60 dark:bg-black/50"
/>
{/* Add region to still be able to drag the window */}
<div data-tauri-drag-region className="absolute top-0 left-0 right-0 h-md" />
{children}
</motion.div>
</FocusTrap>

View File

@@ -1,3 +1,4 @@
import { appWindow } from '@tauri-apps/api/window';
import classnames from 'classnames';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
@@ -6,7 +7,7 @@ import { useKeyValue } from '../hooks/useKeyValue';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import { tryFormatJson } from '../lib/formatters';
import type { HttpHeader, HttpRequest } from '../lib/models';
import { HttpRequestBodyType } from '../lib/models';
import { BODY_TYPE_GRAPHQL, BODY_TYPE_JSON, BODY_TYPE_NONE, BODY_TYPE_XML } from '../lib/models';
import { Editor } from './core/Editor';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
@@ -32,45 +33,64 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
defaultValue: 'body',
});
const tabs: TabItem<HttpRequest['bodyType']>[] = useMemo(
() => [
{
value: 'body',
label: activeRequest?.bodyType ?? 'No Body',
options: {
onChange: async (bodyType: HttpRequest['bodyType']) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType == HttpRequestBodyType.GraphQL) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
[]),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
const tabs: TabItem[] = useMemo(
() =>
activeRequest === null
? []
: [
{
value: 'body',
options: {
value: activeRequest.bodyType,
items: [
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
{ label: 'JSON', value: BODY_TYPE_JSON },
{ label: 'XML', value: BODY_TYPE_XML },
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
],
onChange: async (bodyType) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType == BODY_TYPE_GRAPHQL) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter(
(h) => h.name.toLowerCase() !== 'content-type',
) ?? []),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
setTimeout(() => {
setForceUpdateHeaderEditorKey((u) => u + 1);
}, 100);
}
await updateRequest.mutate(patch);
},
];
setTimeout(() => {
setForceUpdateHeaderEditorKey((u) => u + 1);
}, 100);
}
await updateRequest.mutate(patch);
},
value: activeRequest?.bodyType ?? null,
items: [
{ label: 'No Body', value: null },
{ label: 'JSON', value: HttpRequestBodyType.JSON },
{ label: 'XML', value: HttpRequestBodyType.XML },
{ label: 'GraphQL', value: HttpRequestBodyType.GraphQL },
},
},
{
value: 'auth',
label: 'Auth',
options: {
value: activeRequest.authenticationType,
items: [
{ label: 'No Auth', shortLabel: 'Auth', value: null },
{ label: 'Basic', value: 'basic' },
],
onChange: async (a) => {
await updateRequest.mutate({
authenticationType: a,
authentication: { username: '', password: '' },
});
},
},
},
{ value: 'params', label: 'URL Params' },
{ value: 'headers', label: 'Headers' },
],
},
},
{ value: 'params', label: 'URL Params' },
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
],
[activeRequest?.bodyType, activeRequest?.headers],
[activeRequest?.bodyType, activeRequest?.headers, activeRequest?.authenticationType],
);
const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []);
@@ -79,6 +99,9 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
[],
);
const forceUpdateKey =
activeRequest?.updatedBy === appWindow.label ? undefined : activeRequest?.updatedAt;
return (
<div
style={style}
@@ -86,7 +109,12 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
>
{activeRequest && (
<>
<UrlBar id={activeRequest.id} url={activeRequest.url} method={activeRequest.method} />
<UrlBar
key={forceUpdateKey}
id={activeRequest.id}
url={activeRequest.url}
method={activeRequest.method}
/>
<Tabs
value={activeTab.value}
onChangeValue={activeTab.set}
@@ -110,7 +138,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
<ParametersEditor key={activeRequestId} parameters={[]} onChange={() => null} />
</TabContent>
<TabContent value="body" className="pl-3 mt-1">
{activeRequest.bodyType === HttpRequestBodyType.JSON ? (
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
key={activeRequest.id}
useTemplating
@@ -122,7 +150,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
onChange={handleBodyChange}
format={(v) => tryFormatJson(v)}
/>
) : activeRequest.bodyType === HttpRequestBodyType.XML ? (
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
key={activeRequest.id}
useTemplating
@@ -133,7 +161,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
contentType="text/xml"
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === HttpRequestBodyType.GraphQL ? (
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<GraphQLEditor
key={activeRequest.id}
baseRequest={activeRequest}

View File

@@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useWindowSize } from 'react-use';
import { useSidebarDisplay } from '../hooks/useSidebarDisplay';
import { WINDOW_FLOATING_SIDEBAR_WIDTH } from '../lib/constants';
import { Button } from './core/Button';
import { Overlay } from './Overlay';
import { RequestResponse } from './RequestResponse';
import { ResizeHandle } from './ResizeHandle';
@@ -90,6 +91,14 @@ export default function Workspace() {
[sideWidth, floating],
);
if (windowSize.width <= 100) {
return (
<div>
<Button>Send</Button>
</div>
);
}
return (
<div
style={styles}

View File

@@ -45,7 +45,7 @@ const _Button = forwardRef<any, ButtonProps>(function Button(
className,
'outline-none whitespace-nowrap',
// 'border border-transparent focus-visible:border-focus',
'focus-visible:ring ring-blue-300',
'focus-visible-or-class:ring ring-blue-300',
'rounded-md flex items-center',
colorStyles[color || 'default'],
justify === 'start' && 'justify-start',

View File

@@ -3,7 +3,7 @@ import FocusTrap from 'focus-trap-react';
import { motion } from 'framer-motion';
import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode } from 'react';
import { Children, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useKeyPressEvent, useMount } from 'react-use';
import { useKeyPressEvent } from 'react-use';
import { Portal } from '../Portal';
import { Separator } from './Separator';
import { VStack } from './Stacks';
@@ -83,10 +83,6 @@ interface MenuProps {
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
if (triggerRect === undefined) return null;
useMount(() => {
console.log(document.activeElement);
});
const containerRef = useRef<HTMLDivElement | null>(null);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
@@ -98,6 +94,10 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
setMenuStyles({ maxHeight: windowBox.height - menuBox.top - 5 });
}, []);
useKeyPressEvent('Escape', () => {
onClose();
});
useKeyPressEvent('ArrowUp', () => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? 0) - 1;

View File

@@ -5,10 +5,11 @@ import { Icon } from './Icon';
export interface RadioDropdownItem<T> {
label: string;
shortLabel?: string;
value: T;
}
export interface RadioDropdownProps<T> {
export interface RadioDropdownProps<T = string | null> {
value: T;
onChange: (value: T) => void;
items: RadioDropdownItem<T>[];
@@ -18,8 +19,9 @@ export interface RadioDropdownProps<T> {
export function RadioDropdown<T>({ value, items, onChange, children }: RadioDropdownProps<T>) {
const dropdownItems = useMemo(
() =>
items.map(({ label, value: v }) => ({
items.map(({ label, shortLabel, value: v }) => ({
label,
shortLabel,
onSelect: () => onChange(v),
leftSlot: <Icon icon={value === v ? 'check' : 'empty'} />,
})),

View File

@@ -7,23 +7,27 @@ import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
import { HStack } from '../Stacks';
export type TabItem<T = string> = {
value: string;
label: string;
options?: Omit<RadioDropdownProps<T>, 'children'>;
};
export type TabItem =
| {
value: string;
label: string;
}
| {
value: string;
options: Omit<RadioDropdownProps, 'children'>;
};
interface Props<T = unknown> {
interface Props {
label: string;
value?: string;
onChangeValue: (value: string) => void;
tabs: TabItem<T>[];
tabs: TabItem[];
tabListClassName?: string;
className?: string;
children: ReactNode;
}
export function Tabs<T>({
export function Tabs({
value,
onChangeValue,
label,
@@ -31,7 +35,7 @@ export function Tabs<T>({
tabs,
className,
tabListClassName,
}: Props<T>) {
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const handleTabChange = (value: string) => {
@@ -77,7 +81,8 @@ export function Tabs<T>({
const btnClassName = classnames(
isActive ? 'bg-gray-100 text-gray-800' : 'text-gray-600 hover:text-gray-900',
);
if (t.options) {
if ('options' in t) {
const option = t.options.items.find((i) => i.value === t.options?.value);
return (
<RadioDropdown
key={t.value}
@@ -91,7 +96,7 @@ export function Tabs<T>({
onClick={isActive ? undefined : () => handleTabChange(t.value)}
className={btnClassName}
>
{t.options.items.find((i) => i.value === t.options?.value)?.label ?? ''}
{option?.shortLabel ?? option?.label ?? 'Unknown'}
<Icon icon="triangleDown" className="-mr-1.5" />
</Button>
</RadioDropdown>

View File

@@ -13,10 +13,10 @@ export function Confirm({ hide }: Props) {
return (
<HStack space={2} justifyContent="end">
<Button color="gray" onClick={hide}>
<Button className="focus" color="gray" onClick={hide}>
Cancel
</Button>
<Button ref={focusRef} color="primary">
<Button className="focus" ref={focusRef} color="primary">
Confirm
</Button>
</HStack>

View File

@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { act } from 'react-dom/test-utils';
import { useMemo } from 'react';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useWorkspaces } from './useWorkspaces';

View File

@@ -1,9 +1,11 @@
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 { getRequest } from '../lib/store';
import { requestsQueryKey } from './useRequests';
export function useUpdateRequest(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, unknown, Partial<HttpRequest>>({
mutationFn: async (patch) => {
const request = await getRequest(id);
@@ -13,9 +15,19 @@ export function useUpdateRequest(id: string | null) {
const updatedRequest = { ...request, ...patch };
console.log('UPDATING REQUEST', patch);
await invoke('update_request', {
request: updatedRequest,
});
},
onMutate: async (patch) => {
const request = await getRequest(id);
if (request === null) return;
queryClient.setQueryData(
requestsQueryKey(request?.workspaceId),
(requests: HttpRequest[] | undefined) =>
requests?.map((r) => (r.id === request.id ? { ...r, ...patch } : r)),
);
},
});
}

View File

@@ -1,7 +1,8 @@
export interface BaseModel {
readonly id: string;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly createdAt: string;
readonly updatedAt: string;
readonly updatedBy: string;
}
export interface Workspace extends BaseModel {
@@ -16,11 +17,13 @@ export interface HttpHeader {
enabled?: boolean;
}
export enum HttpRequestBodyType {
GraphQL = 'graphql',
JSON = 'application/json',
XML = 'text/xml',
}
export const BODY_TYPE_NONE = null;
export const BODY_TYPE_GRAPHQL = 'graphql';
export const BODY_TYPE_JSON = 'application/json';
export const BODY_TYPE_XML = 'text/xml';
export const AUTH_TYPE_NONE = null;
export const AUTH_TYPE_BASIC = 'basic';
export interface HttpRequest extends BaseModel {
readonly workspaceId: string;
@@ -29,7 +32,11 @@ export interface HttpRequest extends BaseModel {
name: string;
url: string;
body: string | null;
bodyType: HttpRequestBodyType | null;
bodyType: string | null;
authentication: any | null;
authenticationType: string | null;
auth: Record<string, string | number | null>;
authType: string | null;
method: string;
headers: HttpHeader[];
}