fix: Create workspace below the focus item when created in sidebar (#10370)

* fix sidebar shortcut issues
* fix sort function
This commit is contained in:
Kent Wang authored and GitHub committed 2026-08-11 14:35:58 +08:00
1 parent 6469a6eaeb
commit cb139fff6e
5 files changed
+247 -211

No files matched your search

@@ -26,9 +26,17 @@ interface Props {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
triggerRef: RefObject<HTMLElement | null>;
onWorkspaceCreated: (workspaceId: string) => void;
}
export const SidebarShortcutActionsDropdown = ({ target, storageRules, isOpen, onOpenChange, triggerRef }: Props) => {
export const SidebarShortcutActionsDropdown = ({
target,
storageRules,
isOpen,
onOpenChange,
triggerRef,
onWorkspaceCreated,
}: Props) => {
const [newWorkspaceModalState, setNewWorkspaceModalState] = useState<{
scope: 'collection' | 'design' | 'mock-server' | 'environment' | 'mcp';
isOpen: boolean;
@@ -166,6 +174,8 @@ export const SidebarShortcutActionsDropdown = ({ target, storageRules, isOpen, o
project={target.kind === 'project' ? target.doc : target.project}
storageRules={storageRules}
scope={newWorkspaceModalState.scope}
redirectAfterCreate={false}
onCreateWorkspace={onWorkspaceCreated}
onOpenChange={nextIsOpen => {
setNewWorkspaceModalState(prev =>
prev
@@ -0,0 +1,3 @@
export { NewProjectButton } from './new-project-button';
export { SidebarSearchField } from './sidebar-search-field';
export { SideBarTabList } from './sidebar-tab-list';
@@ -0,0 +1,15 @@
import { Button as BasicButton } from '~/basic-components/button';
import { Icon } from '../../../icon';
export const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisabled?: boolean }) => (
<BasicButton
aria-label="Create new Project"
onPress={onPress}
isDisabled={isDisabled}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<Icon icon="plus" className="h-2.5 w-2.5" />
<span>New Project</span>
</BasicButton>
);
@@ -16,7 +16,7 @@ import {
useState,
} from 'react';
import { Button, GridList, GridListItem, type Selection, Tabs, Tooltip, TooltipTrigger } from 'react-aria-components';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { href, useNavigate, useParams, useSearchParams } from 'react-router';
import * as reactUse from 'react-use';
import { Button as BasicButton } from '~/basic-components/button';
@@ -33,14 +33,17 @@ import { AnalyticsEvent } from '~/ui/analytics';
import type { WorkspaceSortOrder } from '~/ui/components/dropdowns/sidebar-project-dropdown';
import { SidebarShortcutActionsDropdown } from '~/ui/components/dropdowns/sidebar-shortcut-actions-dropdown';
import { SidebarWorkspaceDropdown } from '~/ui/components/dropdowns/sidebar-workspace-dropdown';
import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder';
import { useKeyboardShortcuts } from '~/ui/components/keydown-binder';
import { KongLogo } from '~/ui/components/kong-logo';
import { showModal } from '~/ui/components/modals';
import { AlertModal } from '~/ui/components/modals/alert-modal';
import { AskModal } from '~/ui/components/modals/ask-modal';
import { KonnectSettingsModal } from '~/ui/components/modals/konnect-settings-modal';
import { SidebarSearchField } from '~/ui/components/sidebar/project-navigation-sidebar/components/sidebar-search-field';
import { SideBarTabList } from '~/ui/components/sidebar/project-navigation-sidebar/components/sidebar-tab-list';
import {
NewProjectButton,
SidebarSearchField,
SideBarTabList,
} from '~/ui/components/sidebar/project-navigation-sidebar/components';
import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node';
import { KonnectEnvOnboarding } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding';
import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro';
@@ -101,17 +104,11 @@ function getRelativeTimeString(timestamp: number, now: number = Date.now()): str
return `${days}d ${hours % 24}h ago`;
}
const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisabled?: boolean }) => (
<BasicButton
aria-label="Create new Project"
onPress={onPress}
isDisabled={isDisabled}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<Icon icon="plus" className="h-2.5 w-2.5" />
<span>New Project</span>
</BasicButton>
);
const workspaceManualSortMethod = (a: Workspace, b: Workspace, localOrder: string[]) => {
const aIndex = localOrder.indexOf(a._id);
const bIndex = localOrder.indexOf(b._id);
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
};
const ProjectNavigationSidebarInner = (
{ storageRules, konnectSyncEnabled, onCreateProject, activeTab, setActiveTab }: ProjectNavigationSidebarProps,
@@ -431,16 +428,9 @@ const ProjectNavigationSidebarInner = (
let sortedWorkspaces: Workspace[] = [];
if (workspaceOrder === 'type-manual') {
const localOrder = localWorkspaceOrders?.[projectId];
if (localOrder) {
const orderIndexByWorkspaceId = new Map(localOrder.map((workspaceId, index) => [workspaceId, index]));
sortedWorkspaces = [...workspaces].sort((a, b) => {
const ai = orderIndexByWorkspaceId.get(a._id) ?? Infinity;
const bi = orderIndexByWorkspaceId.get(b._id) ?? Infinity;
return ai - bi;
});
} else {
sortedWorkspaces = [...workspaces].sort((a, b) => sortMethodMap['created-asc'](a, b));
}
sortedWorkspaces = localOrder
? [...workspaces].sort((a, b) => workspaceManualSortMethod(a, b, localOrder))
: [...workspaces].sort((a, b) => sortMethodMap['created-asc'](a, b));
} else {
sortedWorkspaces = [...workspaces].sort((a, b) => sortMethodMap[workspaceOrder](a, b));
}
@@ -712,11 +702,13 @@ const ProjectNavigationSidebarInner = (
const workspaces = organizationWorkspaces.filter(w => w.parentId === targetProjectId);
const currentWorkspaceSortOrder = projectWorkspaceSortOrder[targetProjectId] || 'type-manual';
// Get the base order of workspace before re-order
const localOrder = localWorkspaceOrders?.[targetProjectId];
const baseOrder =
// if current order is manual, use the current order in local state or default sort by created time; otherwise use current order to sort
currentWorkspaceSortOrder === 'type-manual'
? localWorkspaceOrders?.[targetProjectId] ||
[...workspaces].sort((a, b) => sortMethodMap['created-asc'](a, b)).map(w => w._id)
? localOrder
? [...workspaces].sort((a, b) => workspaceManualSortMethod(a, b, localOrder)).map(w => w._id)
: [...workspaces].sort((a, b) => sortMethodMap['created-asc'](a, b)).map(w => w._id)
: [...workspaces].sort((a, b) => sortMethodMap[currentWorkspaceSortOrder](a, b)).map(w => w._id);
const reordered = (baseOrder as string[]).filter((id: string) => id !== draggedId);
@@ -997,7 +989,7 @@ const ProjectNavigationSidebarInner = (
}
}, [selectedItemId]);
useDocBodyKeyboardShortcuts({
useKeyboardShortcuts(() => parentRef.current!, {
sidebar_showCreateDropdown: event => {
if (!isProjectTabActive) {
return;
@@ -1204,32 +1196,32 @@ const ProjectNavigationSidebarInner = (
) : (
<div className="flex items-center gap-1">
{syncing ? (
<Button
aria-label="Cancel sync"
onPress={cancelSync}
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
>
Cancel
<Icon icon="stop-circle" />
</Button>
) : (
<TooltipTrigger delay={300}>
<Button
aria-label="Sync Konnect"
onPress={handleSync}
aria-label="Cancel sync"
onPress={cancelSync}
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
>
<Icon icon="refresh" />
Sync
Cancel
<Icon icon="stop-circle" />
</Button>
<Tooltip
placement="bottom"
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-3 py-1.5 text-xs text-(--color-font) shadow-lg select-none"
>
<LastSyncedLabel lastSyncedAt={lastSyncedAt ?? null} />
</Tooltip>
</TooltipTrigger>
)}
) : (
<TooltipTrigger delay={300}>
<Button
aria-label="Sync Konnect"
onPress={handleSync}
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
>
<Icon icon="refresh" />
Sync
</Button>
<Tooltip
placement="bottom"
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-3 py-1.5 text-xs text-(--color-font) shadow-lg select-none"
>
<LastSyncedLabel lastSyncedAt={lastSyncedAt ?? null} />
</Tooltip>
</TooltipTrigger>
)}
<Button
aria-label="Konnect settings"
onPress={() => setShowKonnectConfigModal(true)}
@@ -1296,65 +1288,78 @@ const ProjectNavigationSidebarInner = (
: ''
}
>
<GridList
aria-label="Project Navigation Tree"
items={virtualizer.getVirtualItems()}
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
className="outline-hidden"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
selectionMode="single"
disabledKeys={pinnedHeaderKeys}
disabledBehavior="all"
dragAndDropHooks={sidebarDragAndDropHooks}
>
{virtualItem => {
const item = visibleFlatItems[virtualItem.index];
if (!item) return null;
<GridList
aria-label="Project Navigation Tree"
items={virtualizer.getVirtualItems()}
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
className="outline-hidden"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
selectionMode="single"
disabledKeys={pinnedHeaderKeys}
disabledBehavior="all"
dragAndDropHooks={sidebarDragAndDropHooks}
>
{virtualItem => {
const item = visibleFlatItems[virtualItem.index];
if (!item) return null;
// Keep the focus ring on the targeted item while the create dropdown is open,
// since keyboard focus has moved into the dropdown menu.
const isShortcutTarget =
isShortcutCreateOpen && shortcutTargetItemId === item.doc._id && item.kind !== 'pinnedRequest';
// Keep the focus ring on the targeted item while the create dropdown is open,
// since keyboard focus has moved into the dropdown menu.
const isShortcutTarget =
isShortcutCreateOpen && shortcutTargetItemId === item.doc._id && item.kind !== 'pinnedRequest';
return (
<GridListItem
key={getSidebarGridListItemId(item)}
id={getSidebarGridListItemId(item)}
textValue={item.doc.name || item.kind}
onAuxClick={e => {
if (e.button === 1 && item.kind === 'collectionChild') {
e.preventDefault();
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: true, shouldNavigate: true, searchParams },
);
}
}}
onPress={async e => {
const docId = item.doc._id;
if (item.kind === 'project') {
if (routeInfo?.resourceId === docId) {
toggleProjectOrWorkspace(docId);
} else {
!isScratchPad &&
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectSwitched,
properties: { project_id: docId },
});
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
return (
<GridListItem
key={getSidebarGridListItemId(item)}
id={getSidebarGridListItemId(item)}
textValue={item.doc.name || item.kind}
onAuxClick={e => {
if (e.button === 1 && item.kind === 'collectionChild') {
e.preventDefault();
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: true, shouldNavigate: true, searchParams },
);
}
} else if (item.kind === 'workspace') {
if (item.doc.scope === 'collection' && enableCollectionFocus) {
// Narrow the sidebar into this collection's tree, and open it
// in the main pane if it isn't already active.
focusWorkspace(docId, item.project._id);
if (routeInfo?.resourceId !== docId || routeInfo?.routeId === 'runner') {
}}
onPress={async e => {
const docId = item.doc._id;
if (item.kind === 'project') {
if (routeInfo?.resourceId === docId) {
toggleProjectOrWorkspace(docId);
} else {
!isScratchPad &&
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectSwitched,
properties: { project_id: docId },
});
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
}
} else if (item.kind === 'workspace') {
if (item.doc.scope === 'collection' && enableCollectionFocus) {
// Narrow the sidebar into this collection's tree, and open it
// in the main pane if it isn't already active.
focusWorkspace(docId, item.project._id);
if (routeInfo?.resourceId !== docId || routeInfo?.routeId === 'runner') {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.doc,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
} else if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') {
toggleProjectOrWorkspace(docId);
} else {
tabNavigate(
{
organization: organizationId,
@@ -1365,116 +1370,105 @@ const ProjectNavigationSidebarInner = (
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
} else if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') {
toggleProjectOrWorkspace(docId);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.doc,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
// Dismiss onboarding when user navigates to the highlighted environment
if (docId === onboardingEnvWorkspaceId) {
dismissEnvOnboarding();
}
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
// Clicking anything inside a collection (folder, request, etc.) focuses it.
// Guard so re-clicking within an already-focused collection doesn't replay the transition.
if (enableCollectionFocus && focusedWorkspaceId !== item.workspace._id) {
focusWorkspace(item.workspace._id, item.project._id);
}
if (
routeInfo?.resourceId === docId &&
models.requestGroup.isRequestGroupId(docId) &&
routeInfo?.routeId !== 'runner'
) {
toggleRequestGroups([docId], item.workspace);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
}
}}
className={`group rounded-xs outline-hidden select-none data-focus-visible:z-10 data-focus-visible:ring-2 data-focus-visible:ring-(--color-surprise) data-focus-visible:ring-inset ${isShortcutTarget ? 'z-10 ring-2 ring-(--color-surprise) ring-inset' : ''}`}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{item.kind === 'project' && (
<ProjectNode
item={item}
onToggle={toggleProjectOrWorkspace}
storageRules={storageRules}
sortOrder={projectWorkspaceSortOrder[item.doc._id] || 'type-manual'}
onSortOrderChange={newSortOrder =>
setProjectWorkspaceSortOrder(prev => {
const newProjectWorkspaceSortOrder = { ...prev, [item.doc._id]: newSortOrder };
return newProjectWorkspaceSortOrder;
})
}
onDeleteProject={projectId =>
deleteProjectFetcher.submit({
organizationId,
projectId,
})
}
/>
)}
{item.kind === 'workspace' && (
<WorkspaceNode
item={item}
onToggle={toggleProjectOrWorkspace}
sortOrder={collectionSortOrders[item.doc._id] || 'type-manual'}
onSortOrderChange={newSortOder => {
if (item.doc.scope === 'collection') {
setCollectionSortOrders(prev => {
const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder };
return newCollectionSortOrders;
});
// Dismiss onboarding when user navigates to the highlighted environment
if (docId === onboardingEnvWorkspaceId) {
dismissEnvOnboarding();
}
}}
highlighted={item.doc._id === onboardingEnvWorkspaceId}
nodeRef={item.doc._id === onboardingEnvWorkspaceId ? setEnvOnboardingNode : undefined}
/>
)}
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
// Clicking anything inside a collection (folder, request, etc.) focuses it.
// Guard so re-clicking within an already-focused collection doesn't replay the transition.
if (enableCollectionFocus && focusedWorkspaceId !== item.workspace._id) {
focusWorkspace(item.workspace._id, item.project._id);
}
if (
routeInfo?.resourceId === docId &&
models.requestGroup.isRequestGroupId(docId) &&
routeInfo?.routeId !== 'runner'
) {
toggleRequestGroups([docId], item.workspace);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
}
}}
className={`group rounded-xs outline-hidden select-none data-focus-visible:z-10 data-focus-visible:ring-2 data-focus-visible:ring-(--color-surprise) data-focus-visible:ring-inset ${isShortcutTarget ? 'z-10 ring-2 ring-(--color-surprise) ring-inset' : ''}`}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{item.kind === 'project' && (
<ProjectNode
item={item}
onToggle={toggleProjectOrWorkspace}
storageRules={storageRules}
sortOrder={projectWorkspaceSortOrder[item.doc._id] || 'type-manual'}
onSortOrderChange={newSortOrder =>
setProjectWorkspaceSortOrder(prev => {
const newProjectWorkspaceSortOrder = { ...prev, [item.doc._id]: newSortOrder };
return newProjectWorkspaceSortOrder;
})
}
onDeleteProject={projectId =>
deleteProjectFetcher.submit({
organizationId,
projectId,
})
}
/>
)}
{item.kind === 'pinnedHeader' && <PinnedHeaderNode depthOffset={treeDepthOffset} />}
{item.kind === 'workspace' && (
<WorkspaceNode
item={item}
onToggle={toggleProjectOrWorkspace}
sortOrder={collectionSortOrders[item.doc._id] || 'type-manual'}
onSortOrderChange={newSortOder => {
if (item.doc.scope === 'collection') {
setCollectionSortOrders(prev => {
const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder };
return newCollectionSortOrders;
});
}
}}
highlighted={item.doc._id === onboardingEnvWorkspaceId}
nodeRef={item.doc._id === onboardingEnvWorkspaceId ? setEnvOnboardingNode : undefined}
/>
)}
{item.kind === 'collectionChild' && (
<RequestNode item={item} onToggleFolder={toggleRequestGroups} depthOffset={treeDepthOffset} />
)}
{item.kind === 'pinnedHeader' && <PinnedHeaderNode depthOffset={treeDepthOffset} />}
{item.kind === 'pinnedRequest' && (
<RequestNode item={item} onToggleFolder={toggleRequestGroups} depthOffset={treeDepthOffset} />
)}
{item.kind === 'collectionChild' && (
<RequestNode item={item} onToggleFolder={toggleRequestGroups} depthOffset={treeDepthOffset} />
)}
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
{item.kind === 'pinnedRequest' && (
<RequestNode item={item} onToggleFolder={toggleRequestGroups} depthOffset={treeDepthOffset} />
)}
{item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? (
<EmptyNode item={item} storageRules={storageRules} depthOffset={treeDepthOffset} />
) : null}
</GridListItem>
);
}}
</GridList>
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
{item.kind === 'emptyProject' ||
item.kind === 'emptyCollection' ||
item.kind === 'emptyFolder' ? (
<EmptyNode item={item} storageRules={storageRules} depthOffset={treeDepthOffset} />
) : null}
</GridListItem>
);
}}
</GridList>
</div>
</div>
{shortcutTargetItem && (
@@ -1484,6 +1478,19 @@ const ProjectNavigationSidebarInner = (
isOpen={isShortcutCreateOpen}
onOpenChange={setIsShortcutCreateOpen}
triggerRef={shortcutCreateTriggerRef}
onWorkspaceCreated={newWorkspaceId => {
const projectId =
shortcutTargetItem.kind === 'workspace' ? shortcutTargetItem.project._id : shortcutTargetItem.doc._id;
const targetWorkspaceId = shortcutTargetItem.kind === 'workspace' ? shortcutTargetItem.doc._id : null;
handleLocalWorkspaceReorder(projectId, projectId, newWorkspaceId, targetWorkspaceId, 'after');
return navigate(
href(`/organization/:organizationId/project/:projectId/workspace/:workspaceId`, {
organizationId,
projectId,
workspaceId: newWorkspaceId,
}),
);
}}
/>
)}
@@ -11,6 +11,7 @@ const createAppDataQueryClient = () =>
staleTime: Infinity,
gcTime: Infinity,
retry: false,
// Local database queries should always be fetched regardless of network connectivity.
networkMode: 'always',
},
},