fix: first request feedback

This commit is contained in:
Curry Yang
2026-06-02 15:39:43 +08:00
parent d5b5a154c7
commit a672016b1e
7 changed files with 134 additions and 36 deletions

View File

@@ -109,18 +109,15 @@ export function SelectPopover<T extends SelectPopoverItem>({
items={[...items]}
selectedKeys={selectedKey === null || selectedKey === undefined ? [] : [selectedKey]}
selectionMode="single"
disallowEmptySelection
onSelectionChange={keys => {
if (keys === 'all' || !keys) {
return;
}
const [nextKey] = keys.values();
const [key] = keys.values();
if (nextKey === undefined) {
return;
}
onSelectionChange(nextKey);
onSelectionChange(key);
setOpen(false);
}}
renderEmptyState={() => (emptyState ? <div className="p-3 text-sm text-(--hl)">{emptyState}</div> : null)}
@@ -134,6 +131,11 @@ export function SelectPopover<T extends SelectPopoverItem>({
id={item.id}
textValue={item.textValue ?? item.label}
isDisabled={item.isDisabled}
onPress={() => {
if (String(item.id) === String(selectedKey)) {
setOpen(false);
}
}}
className={twMerge(
'flex min-h-8 w-full items-center gap-2 rounded-sm px-2 text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold',
itemClassName,

View File

@@ -34,7 +34,7 @@ import { sortMethodMap } from '~/common/sorting';
import { useRootLoaderData } from '~/root';
import { useOrganizationLoaderData } from '~/routes/organization';
import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file';
import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId';
import { useProjectLoaderData, useProjectRouteContext } from '~/routes/organization.$organizationId.project.$projectId';
import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new';
import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules';
import { AnalyticsEvent, trackOnceDaily } from '~/ui/analytics';
@@ -79,6 +79,7 @@ export interface ProjectLoaderData {
const Component = () => {
const { localFiles, activeProject, activeProjectGitRepository, projects, remoteFilesPromise } =
useProjectLoaderData()!;
const { activeSidebarTab } = useProjectRouteContext();
const { organizationId, projectId } = useParams() as {
organizationId: string;
projectId: string;
@@ -336,15 +337,19 @@ const Component = () => {
<Fragment>
<OrganizationTabList showActiveStatus={false} />
<div className="px-4 pt-4">
<FirstRequestCreation
greetingName={greetingName}
collectionItems={collectionItems}
selectedCollectionId={selectedCollectionId}
onSelectedCollectionChange={setSelectedCollectionId}
onCreateCollection={() => {
setNewWorkspaceModalState({ scope: 'collection', isOpen: true, redirect: false, source: 'home-page' });
}}
/>
{activeSidebarTab === 'projects' && (
<FirstRequestCreation
greetingName={greetingName}
collectionItems={collectionItems}
selectedCollectionId={selectedCollectionId}
onSelectedCollectionChange={setSelectedCollectionId}
onCreateDesignDocument={createNewDocument}
onCreateCollection={() => {
setNewWorkspaceModalState({ scope: 'collection', isOpen: true, redirect: false, source: 'home-page' });
}}
onImportFrom={() => setImportModalType('file')}
/>
)}
</div>
{activeProject ? (
<div className="flex w-full flex-col overflow-hidden">

View File

@@ -1,9 +1,9 @@
import { getLearningFeature } from 'insomnia-api';
import { models, services } from 'insomnia-data';
import { useEffect, useRef, useState } from 'react';
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from 'react';
import { Button, Heading } from 'react-aria-components';
import { type ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { href, Outlet, redirect, useParams, useRouteLoaderData } from 'react-router';
import { href, Outlet, redirect, useOutletContext, useParams, useRouteLoaderData, useSearchParams } from 'react-router';
import * as reactUse from 'react-use';
import { logout } from '~/account/session';
@@ -18,7 +18,11 @@ import {
import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules';
import { ProjectModal } from '~/ui/components/modals/project-modal';
import { ScratchPadTutorialPanel } from '~/ui/components/panes/scratchpad-tutorial-pane';
import { ProjectNavigationSidebar } from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar';
import {
ProjectNavigationSidebar,
type ProjectNavigationSidebarHandle,
type ProjectNavigationSidebarTabId,
} from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar';
import { SyncBar } from '~/ui/components/sidebar/sync-bar';
import { useSidebarContext } from '~/ui/context/app/insomnia-sidebar-context';
import { GitFileIssuesProvider, useProjectGitFileIssues } from '~/ui/hooks/use-git-file-issues';
@@ -131,11 +135,22 @@ export function useProjectLoaderData() {
return useRouteLoaderData<typeof clientLoader>('routes/organization.$organizationId.project.$projectId');
}
export interface ProjectRouteContextValue {
activeSidebarTab: ProjectNavigationSidebarTabId;
setActiveSidebarTab: Dispatch<SetStateAction<ProjectNavigationSidebarTabId | undefined>>;
}
export function useProjectRouteContext() {
return useOutletContext<ProjectRouteContextValue>();
}
const Component = ({ loaderData }: Route.ComponentProps) => {
const { organizationId } = useParams() as {
organizationId: string;
projectId: string;
};
const [searchParams] = useSearchParams();
const { activeProject, learningFeaturePromise } = loaderData;
const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` });
@@ -160,6 +175,11 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
}, [isSidebarCollapsed]);
const { features } = useOrganizationPermissions();
const [storedSidebarTab, setActiveSidebarTab] = reactUse.useLocalStorage<ProjectNavigationSidebarTabId>(
`${organizationId}:sidebar-tab`,
'projects',
);
const activeSidebarTab = !features.konnectSync.enabled ? 'projects' : (storedSidebarTab ?? 'projects');
const isScratchPad = models.project.isScratchpadProject(activeProject);
const gitRepositoryId =
@@ -171,6 +191,15 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
gitRepositoryId,
});
const navigationSidebarRef = useRef<ProjectNavigationSidebarHandle>(null);
useEffect(() => {
const isExpanded = searchParams.get('isExpended') === 'true';
if (navigationSidebarRef.current && isExpanded && activeProject) {
navigationSidebarRef.current.expandProject(activeProject._id);
}
}, [searchParams, activeProject]);
return (
<>
<PanelGroup
@@ -190,9 +219,12 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
>
<div className="flex flex-1 flex-col divide-y divide-solid divide-(--hl-md) overflow-hidden">
<ProjectNavigationSidebar
activeTab={activeSidebarTab}
storageRules={storageRules}
konnectSyncEnabled={features.konnectSync.enabled}
onCreateProject={() => setIsNewProjectModalOpen(true)}
setActiveTab={setActiveSidebarTab}
ref={navigationSidebarRef}
/>
{isScratchPad && <ScratchPadTutorialPanel />}
{!isLearningFeatureDismissed && learningFeature?.active && (
@@ -226,7 +258,12 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
/>
<Panel id="pane-one" className="pane-one theme--pane flex flex-col">
<GitFileIssuesProvider value={gitFileIssues}>
<Outlet />
<Outlet
context={{
activeSidebarTab,
setActiveSidebarTab,
}}
/>
</GitFileIssuesProvider>
</Panel>
</PanelGroup>

View File

@@ -114,6 +114,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
await services.request.create({
parentId: parentId || workspaceId,
url: req.url,
name: req.name || 'New Request',
method: req.method,
headers: req.headers,
body: req.body as RequestBody,

View File

@@ -83,7 +83,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
parentId: project._id,
});
return redirect(`/organization/${organizationId}/project/${project._id}`);
return redirect(`/organization/${organizationId}/project/${project._id}?isExpended=true`);
} catch (error) {
console.warn('[project] Failed to auto-create initial local project', error);
}

View File

@@ -1,11 +1,13 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import type { Request } from 'insomnia-data';
import { constructKeyCombinationDisplay, getPlatformKeyCombinations } from 'insomnia-data/common';
import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Button } from '~/basic-components/button';
import { SelectPopover } from '~/basic-components/select-popover';
import { getProjectRecentRequests, type RecentProjectRequest } from '~/common/project';
import { useRootLoaderData } from '~/root';
import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new';
import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new';
import { createKeybindingsHandler, useKeyboardShortcuts } from '~/ui/components/keydown-binder';
@@ -72,6 +74,8 @@ interface FirstRequestCreationProps {
selectedCollectionId: string | null;
onSelectedCollectionChange: (collectionId: string | null) => void;
onCreateCollection: () => void;
onCreateDesignDocument: () => void;
onImportFrom: () => void;
}
export const FirstRequestCreation = ({
@@ -80,6 +84,8 @@ export const FirstRequestCreation = ({
selectedCollectionId,
onSelectedCollectionChange,
onCreateCollection,
onCreateDesignDocument,
onImportFrom,
}: FirstRequestCreationProps) => {
const navigate = useNavigate();
const { organizationId, projectId } = useParams() as {
@@ -94,6 +100,7 @@ export const FirstRequestCreation = ({
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [requestInput, setRequestInput] = useState('');
const [recentRequests, setRecentRequests] = useState<RecentProjectRequest[]>([]);
const [isRequestInputFocused, setIsRequestInputFocused] = useState(false);
const [curlParseError, setCurlParseError] = useState(false);
const [selectOpen, setSelectOpen] = useState(false);
const trimmedInput = requestInput.trim();
@@ -324,8 +331,23 @@ export const FirstRequestCreation = ({
icon: <SvgIcon icon="graphql" />,
onClick: handleCreateGithubLookupRequest,
},
{
id: 'create-openapi-spec',
label: 'Create OpenAPI spec',
icon: <Icon icon="file" className="text-(--font-size-xl)" />,
onClick: onCreateDesignDocument,
},
{
id: 'import-files',
label: 'Import files',
icon: <Icon icon="file-import" className="text-(--font-size-xl)" />,
onClick: onImportFrom,
},
];
const { settings } = useRootLoaderData()!;
const keyComb = getPlatformKeyCombinations(settings.hotKeyRegistry.request_createHTTP)[0];
const shortcutDisplay = constructKeyCombinationDisplay(keyComb, false);
return (
<>
<div className="rounded-sm bg-[radial-gradient(95.72%_95.72%_at_-0.32%_2.6%,var(--hl-md)_0%,var(--hl-xs)_100%),radial-gradient(100%_100.41%_at_100%_99.92%,var(--hl-md)_0%,var(--hl-xs)_100%)] p-px">
@@ -339,15 +361,19 @@ export const FirstRequestCreation = ({
: `We have a sneaking suspicion that you came here to send a request, so lets get started!`}
</p>
<div className="mt-8 w-[50%] min-w-100">
<div className="flex aspect-540/127 flex-col overflow-hidden rounded-lg border border-[#3F3F46] bg-(--color-bg) shadow-[0_0_0_4px_#0044F433]">
<div
className={`flex aspect-540/127 flex-col overflow-hidden rounded-lg border border-(--hl-md) bg-(--color-bg) ${isRequestInputFocused ? 'shadow-[0_0_0_4px_#0044F433]' : ''}`}
>
<div className="flex-1 px-4 pt-3 pb-2">
<textarea
ref={inputRef}
autoFocus
aria-label="Request endpoint or cURL input"
className="text-md h-full w-full flex-1 resize-none font-mono"
placeholder="Enter an endpoint URL or paste cURL, or ⌘N for a new blank request"
placeholder={`Enter an endpoint URL or paste cURL, or ${shortcutDisplay} for a new blank request`}
value={requestInput}
onFocus={() => setIsRequestInputFocused(true)}
onBlur={() => setIsRequestInputFocused(false)}
onChange={event => {
setCurlParseError(false);
setRequestInput(event.target.value);
@@ -383,7 +409,7 @@ export const FirstRequestCreation = ({
New Collection
</Button>
}
triggerClassName="h-8 rounded-md px-3 text-sm"
triggerClassName="h-8 rounded-md px-3 text-sm data-[focus-visible=true]:!ring-0"
popoverClassName="w-[240px]"
dialogClassName="w-[240px]"
renderTrigger={selectedItem => (

View File

@@ -2,7 +2,18 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import type { StorageRules } from 'insomnia-api';
import type { RequestGroup, Workspace } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
type Dispatch,
type ForwardedRef,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { Button, GridList, GridListItem, Input, SearchField, Tab, TabList, Tabs } from 'react-aria-components';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import * as reactUse from 'react-use';
@@ -50,10 +61,18 @@ import { WorkspaceNode } from './workspace-node';
interface ProjectNavigationSidebarProps {
storageRules: StorageRules;
activeNodeId?: string;
activeTab: ProjectNavigationSidebarTabId;
konnectSyncEnabled: boolean;
onCreateProject: () => void;
setActiveTab: Dispatch<SetStateAction<ProjectNavigationSidebarTabId | undefined>>;
}
export interface ProjectNavigationSidebarHandle {
expandProject: (projectId: string) => void;
}
export type ProjectNavigationSidebarTabId = 'projects' | 'konnect';
const SidebarSearchField = ({
value,
isDisabled,
@@ -136,11 +155,10 @@ const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisa
</BasicButton>
);
export const ProjectNavigationSidebar = ({
storageRules,
konnectSyncEnabled,
onCreateProject,
}: ProjectNavigationSidebarProps) => {
const ProjectNavigationSidebarInner = (
{ storageRules, konnectSyncEnabled, onCreateProject, activeTab, setActiveTab }: ProjectNavigationSidebarProps,
ref: ForwardedRef<ProjectNavigationSidebarHandle>,
) => {
const navigate = useNavigate();
const { organizationId, projectId: activeProjectId } = useParams() as {
organizationId: string;
@@ -177,15 +195,10 @@ export const ProjectNavigationSidebar = ({
`${organizationId}:project-navigation-konnect-filter`,
'',
);
const [storedTab, setActiveTab] = reactUse.useLocalStorage<'projects' | 'konnect'>(
`${organizationId}:sidebar-tab`,
'projects',
);
const [expandedProjectAndWorkspaceIds, setExpandedProjectAndWorkspaceIds] = reactUse.useLocalStorage<string[]>(
`${organizationId}:nav-expanded-projects-and-workspaces`,
[],
);
const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects');
const isProjectTabActive = activeTab === 'projects';
const { syncing, progress, startSync, cancelSync } = useKonnectSync();
@@ -723,6 +736,16 @@ export const ProjectNavigationSidebar = ({
[expandedProjectAndWorkspaceIds, projectNavigationSidebarFilter, setExpandedProjectAndWorkspaceIds],
);
useImperativeHandle(
ref,
() => ({
expandProject(projectId: string) {
expandProjectOrWorkspaces([projectId]);
},
}),
[expandProjectOrWorkspaces],
);
const toggleRequestGroups = useCallback(
async (requestGroupIds: string[], workspace: Workspace, collapsed?: boolean) => {
if (requestGroupIds.length === 0) {
@@ -884,7 +907,7 @@ export const ProjectNavigationSidebar = ({
return (
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
<Tabs selectedKey={activeTab} onSelectionChange={key => setActiveTab(key as 'projects' | 'konnect')}>
<Tabs selectedKey={activeTab} onSelectionChange={key => setActiveTab(key as ProjectNavigationSidebarTabId)}>
<SideBarTabList
konnectSyncEnabled={konnectSyncEnabled}
isScratchPad={isScratchPad}
@@ -1194,6 +1217,10 @@ export const ProjectNavigationSidebar = ({
);
};
export const ProjectNavigationSidebar = forwardRef<ProjectNavigationSidebarHandle, ProjectNavigationSidebarProps>(
ProjectNavigationSidebarInner,
);
export const EmptyProjectNavigationSidebar = ({ onCreateProject }: { onCreateProject: () => void }) => {
const { organizationId } = useParams() as { organizationId: string };
const isScratchPad = models.organization.isScratchpadOrganizationId(organizationId);