Request history navigator

This commit is contained in:
Gregory Schier
2023-04-09 15:26:54 -07:00
parent fb38708fad
commit feec6fedfa
20 changed files with 277 additions and 67 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
<script src="http://localhost:8097"></script>
<style>
body {
background-color: white;

View File

@@ -1,7 +1,7 @@
import { createBrowserRouter, Navigate, Outlet, RouterProvider } from 'react-router-dom';
import { routePaths } from '../hooks/useRoutes';
import { routePaths } from '../hooks/useAppRoutes';
import { GlobalHooks } from './GlobalHooks';
import RouteError from './RouteError';
import { TauriListeners } from './TauriListeners';
import Workspace from './Workspace';
import Workspaces from './Workspaces';
@@ -9,12 +9,7 @@ const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
element: (
<>
<Outlet />
<TauriListeners />
</>
),
element: <Layout />,
children: [
{
path: '/',
@@ -42,3 +37,12 @@ const router = createBrowserRouter([
export function AppRouter() {
return <RouterProvider router={router} />;
}
function Layout() {
return (
<>
<Outlet />
<GlobalHooks />
</>
);
}

View File

@@ -13,7 +13,7 @@ import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
export function TauriListeners() {
export function GlobalHooks() {
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);

View File

@@ -0,0 +1,106 @@
import { useMemo, useRef } from 'react';
import { useKeyPressEvent } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
export function RecentRequestsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
useKeyPressEvent('Control', undefined, () => {
// Key up
dropdownRef.current?.select?.();
});
useKeyPressEvent('Tab', (e) => {
if (!e.ctrlKey) return;
if (!dropdownRef.current?.isOpen) {
// Set to 1 because the first item is the active request
dropdownRef.current?.open(e.shiftKey ? -1 : 1);
}
if (e.shiftKey) {
dropdownRef.current?.prev?.();
} else {
dropdownRef.current?.next?.();
}
});
const activeRequest = useActiveRequest();
const activeWorkspaceId = useActiveWorkspaceId();
const recentRequestIds = useRecentRequests();
const requests = useRequests();
const routes = useAppRoutes();
const deleteRequest = useDeleteRequest(activeRequest?.id ?? null);
const duplicateRequest = useDuplicateRequest({
id: activeRequest?.id ?? null,
navigateAfter: true,
});
const items = useMemo<DropdownItem[]>(() => {
if (activeWorkspaceId === null) return [];
const recentRequestItems: DropdownItem[] = [];
for (const id of recentRequestIds) {
const request = requests.find((r) => r.id === id);
if (request === undefined) continue;
recentRequestItems.push({
label: request.name,
leftSlot: <CountBadge className="!mx-0" count={recentRequestItems.length + 1} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
workspaceId: activeWorkspaceId,
});
},
});
}
// Show max 30 items
const fixedItems: DropdownItem[] = [
// {
// label: 'Duplicate',
// onSelect: duplicateRequest.mutate,
// leftSlot: <Icon icon="copy" />,
// rightSlot: <HotKey modifier="Meta" keyName="D" />,
// },
// {
// label: 'Delete',
// onSelect: deleteRequest.mutate,
// variant: 'danger',
// leftSlot: <Icon icon="trash" />,
// },
];
// No recent requests to show
if (recentRequestItems.length === 0) {
return fixedItems;
}
return [
// ...fixedItems,
// { type: 'separator', label: 'Recent Requests' },
...recentRequestItems.slice(0, 20),
];
}, [activeWorkspaceId, recentRequestIds, requests, routes]);
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
size="sm"
className="pointer-events-auto flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none"
>
{activeRequest?.name}
</Button>
</Dropdown>
);
}

View File

@@ -29,6 +29,7 @@ export function RequestActionsDropdown({ requestId, children }: Props) {
{
label: 'Delete',
onSelect: deleteRequest.mutate,
variant: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Yaak Settings' },

View File

@@ -162,7 +162,7 @@ export const RequestPane = memo(function RequestPane({ style, fullHeight, classN
label="Request"
onChangeValue={setActiveTab}
tabs={tabs}
tabListClassName="mt-2"
tabListClassName="mt-2 !mb-1.5"
>
<TabContent value="auth">
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (

View File

@@ -103,7 +103,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
<HStack
alignItems="center"
className={classnames(
'italic text-gray-700 text-sm w-full flex-shrink-0',
'text-gray-700 text-sm w-full flex-shrink-0',
// Remove a bit of space because the tabs have lots too
'-mb-1.5',
)}

View File

@@ -1,10 +1,10 @@
import classnames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { useRoutes } from '../hooks/useRoutes';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button';
@@ -25,7 +25,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceDropdown({ classN
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const prompt = usePrompt();
const routes = useRoutes();
const routes = useAppRoutes();
const items: DropdownItem[] = useMemo(() => {
const workspaceItems = workspaces.map((w) => ({

View File

@@ -3,6 +3,7 @@ import { memo } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { SidebarActions } from './SidebarActions';
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
@@ -24,8 +25,8 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<SidebarActions />
<WorkspaceActionsDropdown className="pointer-events-auto" />
</HStack>
<div className="flex-[2] text-center text-gray-800 text-sm truncate pointer-events-none">
{activeRequest?.name}
<div>
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
{activeRequest && (

View File

@@ -1,10 +1,10 @@
import { Navigate } from 'react-router-dom';
import { useRoutes } from '../hooks/useRoutes';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Heading } from './core/Heading';
export default function Workspaces() {
const routes = useRoutes();
const routes = useAppRoutes();
const workspaces = useWorkspaces();
const workspace = workspaces[0];

View File

@@ -1,17 +1,21 @@
import classnames from 'classnames';
interface Props {
count: number;
className?: string;
}
export function CountBadge({ count }: Props) {
export function CountBadge({ count, className }: Props) {
if (count === 0) return null;
return (
<>
<div
aria-hidden
className="opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono"
>
{count}
</div>
</>
<div
aria-hidden
className={classnames(
className,
'opacity-70 border border-highlight text-3xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}
>
{count}
</div>
);
}

View File

@@ -5,8 +5,10 @@ import type { CSSProperties, HTMLAttributes, MouseEvent, ReactElement, ReactNode
import React, {
Children,
cloneElement,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
@@ -39,21 +41,50 @@ export interface DropdownProps {
items: DropdownItem[];
}
export function Dropdown({ children, items }: DropdownProps) {
export interface DropdownRef {
isOpen: boolean;
open: (activeIndex?: number) => void;
close?: () => void;
next?: () => void;
prev?: () => void;
select?: () => void;
}
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items }: DropdownProps,
ref,
) {
const [open, setOpen] = useState<boolean>(false);
const ref = useRef<HTMLButtonElement>(null);
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number>();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
useImperativeHandle(ref, () => ({
...menuRef.current,
isOpen: open,
open: (activeIndex?: number) => {
if (activeIndex === undefined) {
setDefaultSelectedIndex(undefined);
} else {
setDefaultSelectedIndex(activeIndex >= 0 ? activeIndex : items.length + activeIndex);
}
setOpen(true);
},
}));
const child = useMemo(() => {
const existingChild = Children.only(children);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const props: any = {
...existingChild.props,
ref,
ref: buttonRef,
'aria-haspopup': 'true',
onClick:
existingChild.props?.onClick ??
((e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDefaultSelectedIndex(undefined);
setOpen((o) => !o);
}),
};
@@ -62,37 +93,48 @@ export function Dropdown({ children, items }: DropdownProps) {
const handleClose = useCallback(() => {
setOpen(false);
ref.current?.focus();
buttonRef.current?.focus();
}, []);
useEffect(() => {
ref.current?.setAttribute('aria-expanded', open.toString());
buttonRef.current?.setAttribute('aria-expanded', open.toString());
}, [open]);
const triggerRect = useMemo(() => {
if (!open) return null;
return ref.current?.getBoundingClientRect();
return buttonRef.current?.getBoundingClientRect();
}, [open]);
return (
<>
{child}
{open && triggerRect && (
<Menu items={items} triggerRect={triggerRect} onClose={handleClose} />
<Menu
ref={menuRef}
defaultSelectedIndex={defaultSelectedIndex}
items={items}
triggerRect={triggerRect}
onClose={handleClose}
/>
)}
</>
);
}
});
interface MenuProps {
className?: string;
defaultSelectedIndex?: number;
items: DropdownProps['items'];
triggerRect: DOMRect;
onClose: () => void;
}
function Menu({ className, items, onClose, triggerRect }: MenuProps) {
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen'>, MenuProps>(function Menu(
{ className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps,
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(defaultSelectedIndex ?? null);
const [menuStyles, setMenuStyles] = useState<CSSProperties>({});
// Calculate the max height so we can scroll
@@ -119,8 +161,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
onClose();
});
useKeyPressEvent('ArrowUp', (e) => {
e.preventDefault();
const handlePrev = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? 0) - 1;
const maxTries = items.length;
@@ -135,10 +176,9 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
}
return nextIndex;
});
});
}, [items]);
useKeyPressEvent('ArrowDown', (e) => {
e.preventDefault();
const handleNext = useCallback(() => {
setSelectedIndex((currIndex) => {
let nextIndex = (currIndex ?? -1) + 1;
const maxTries = items.length;
@@ -153,8 +193,44 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
}
return nextIndex;
});
}, [items]);
useKeyPressEvent('ArrowUp', (e) => {
e.preventDefault();
handlePrev();
});
useKeyPressEvent('ArrowDown', (e) => {
e.preventDefault();
handleNext();
});
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
useImperativeHandle(
ref,
() => ({
close: onClose,
prev: handlePrev,
next: handleNext,
select: () => {
const item = items[selectedIndex ?? -1] ?? null;
if (!item) return;
handleSelect(item);
},
}),
[handleNext, handlePrev, handleSelect, items, onClose, selectedIndex],
);
const { containerStyles, triangleStyles } = useMemo<{
containerStyles: CSSProperties;
triangleStyles: CSSProperties;
@@ -173,17 +249,6 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
return { containerStyles, triangleStyles };
}, [triggerRect]);
const handleSelect = useCallback(
(i: DropdownItem) => {
onClose();
setSelectedIndex(null);
if (i.type !== 'separator') {
i.onSelect?.();
}
},
[onClose],
);
const handleFocus = useCallback(
(i: DropdownItem) => {
const index = items.findIndex((item) => item === i) ?? null;
@@ -192,8 +257,6 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
[items],
);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
return (
<Portal name="dropdown">
<FocusTrap>
@@ -251,7 +314,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
</FocusTrap>
</Portal>
);
}
});
interface MenuItemProps {
className?: string;
@@ -293,7 +356,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
)}
{...props}
>
{item.leftSlot && <div className="w-6">{item.leftSlot}</div>}
{item.leftSlot && <div className="w-6 flex justify-start">{item.leftSlot}</div>}
<div>{item.label}</div>
{item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
</button>

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ export const routePaths = {
},
};
export function useRoutes() {
export function useAppRoutes() {
const navigate = useNavigate();
return useMemo(
() => ({

View File

@@ -2,12 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey, useRequests } from './useRequests';
import { useRoutes } from './useRoutes';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
const routes = useAppRoutes();
const requests = useRequests();
const queryClient = useQueryClient();

View File

@@ -1,11 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { useRoutes } from './useRoutes';
import { useAppRoutes } from './useAppRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
const routes = useRoutes();
const routes = useAppRoutes();
const queryClient = useQueryClient();
return useMutation<Workspace, unknown, Pick<Workspace, 'name'>>({
mutationFn: (patch) => {

View File

@@ -3,15 +3,15 @@ import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import type { Workspace } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { useConfirm } from './useConfirm';
import { requestsQueryKey } from './useRequests';
import { useRoutes } from './useRoutes';
import { workspacesQueryKey } from './useWorkspaces';
export function useDeleteWorkspace(workspace: Workspace | null) {
const queryClient = useQueryClient();
const activeWorkspaceId = useActiveWorkspaceId();
const routes = useRoutes();
const routes = useAppRoutes();
const confirm = useConfirm();
return useMutation<Workspace | null, string>({

View File

@@ -2,8 +2,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useAppRoutes } from './useAppRoutes';
import { requestsQueryKey } from './useRequests';
import { useRoutes } from './useRoutes';
export function useDuplicateRequest({
id,
@@ -13,7 +13,7 @@ export function useDuplicateRequest({
navigateAfter: boolean;
}) {
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
const routes = useAppRoutes();
const queryClient = useQueryClient();
return useMutation<HttpRequest, string>({
mutationFn: async () => {

View File

@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { createGlobalState, useEffectOnce, useLocalStorage } from 'react-use';
import { useActiveRequestId } from './useActiveRequestId';
const useHistoryState = createGlobalState<string[]>([]);
export function useRecentRequests() {
const [history, setHistory] = useHistoryState();
const activeRequestId = useActiveRequestId();
const [lsState, setLSState] = useLocalStorage<string[]>('recent_requests', []);
useEffect(() => {
setLSState(history);
}, [history, setLSState]);
useEffectOnce(() => {
if (lsState) {
setHistory(lsState);
}
});
useEffect(() => {
setHistory((h: string[]) => {
if (activeRequestId === null) return h;
const withoutCurrentRequest = h.filter((id) => id !== activeRequestId);
return [activeRequestId, ...withoutCurrentRequest];
});
}, [activeRequestId, setHistory]);
return history;
}