From 41a0f0caffe5782169afbbc92608e422e4f0ed4f Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 7 May 2026 17:35:52 +0800 Subject: [PATCH] feat: Pin/unpin request and collection request sorting (#9903) add basic sort support support toggle header & sidebar add sort support fix issues from comment --- .../node-src/database/database-nedb.ts | 8 + .../insomnia-data/node-src/services/index.ts | 4 + .../services/socket-io-request-meta.ts | 59 ++++ .../services/websocket-request-meta.ts | 59 ++++ .../src/insomnia-data/src/models/db-models.ts | 2 + .../src/models/socket-io-request-meta.ts | 26 ++ .../src/insomnia-data/src/models/types.ts | 2 + .../src/models/websocket-request-meta.ts | 26 ++ packages/insomnia/src/models/index.ts | 2 + packages/insomnia/src/models/types.ts | 2 + ...ion.$organizationId.project.$projectId.tsx | 24 +- ...d.debug.request.$requestId.update-meta.tsx | 16 +- ...ject.$projectId.workspace.$workspaceId.tsx | 17 +- packages/insomnia/src/routes/organization.tsx | 228 +++++++++------ .../components/dropdowns/project-dropdown.tsx | 2 +- .../dropdowns/request-actions-dropdown.tsx | 9 +- .../dropdowns/sidebar-workspace-dropdown.tsx | 91 +++++- .../insomnia/src/ui/components/kong-logo.tsx | 27 ++ .../project-navigation-sidebar/empty-node.tsx | 261 ++++++++++++++++++ .../project-navigation-sidebar-utils.ts | 43 ++- .../project-navigation-sidebar.tsx | 242 ++++++++++++---- .../project-node.tsx | 2 +- .../request-node.tsx | 144 +++++++--- .../project-navigation-sidebar/types.ts | 36 ++- .../unsynced-workspace-node.tsx | 48 ++-- .../workspace-node.tsx | 24 +- packages/insomnia/src/ui/event-bus.ts | 3 + packages/insomnia/src/ui/hooks/use-request.ts | 20 +- 28 files changed, 1174 insertions(+), 253 deletions(-) create mode 100644 packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts create mode 100644 packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts create mode 100644 packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts create mode 100644 packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts create mode 100644 packages/insomnia/src/ui/components/kong-logo.tsx create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx diff --git a/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts b/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts index 0323b86407..2226493abc 100644 --- a/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts +++ b/packages/insomnia/src/insomnia-data/node-src/database/database-nedb.ts @@ -326,6 +326,10 @@ export const createNedbDatabase = ( ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.SocketIORequest.db'), }), + SocketIORequestMeta: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.SocketIORequestMeta.db'), + }), SocketIOResponse: new NeDB({ ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.SocketIOResponse.db'), @@ -358,6 +362,10 @@ export const createNedbDatabase = ( ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.WebSocketRequest.db'), }), + WebSocketRequestMeta: new NeDB({ + ...defaultConfig, + filename: fsPath.join(dbPath, 'insomnia.WebSocketRequestMeta.db'), + }), WebSocketResponse: new NeDB({ ...defaultConfig, filename: fsPath.join(dbPath, 'insomnia.WebSocketResponse.db'), diff --git a/packages/insomnia/src/insomnia-data/node-src/services/index.ts b/packages/insomnia/src/insomnia-data/node-src/services/index.ts index f5079aecb7..a914ba3046 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/index.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/index.ts @@ -28,6 +28,7 @@ import * as runnerTestResultService from './runner-test-result'; import * as settingsService from './settings'; import * as socketIOPayloadService from './socket-io-payload'; import * as socketIORequestService from './socket-io-request'; +import * as socketIORequestMetaService from './socket-io-request-meta'; import * as socketIOResponseService from './socket-io-response'; import * as statsService from './stats'; import * as unitTestService from './unit-test'; @@ -36,6 +37,7 @@ import * as unitTestSuiteService from './unit-test-suite'; import * as userSessionService from './user-session'; import * as webSocketPayloadService from './websocket-payload'; import * as webSocketRequestService from './websocket-request'; +import * as webSocketRequestMetaService from './websocket-request-meta'; import * as webSocketResponseService from './websocket-response'; import * as workspaceService from './workspace'; import * as workspaceMetaService from './workspace-meta'; @@ -81,8 +83,10 @@ export const servicesNodeImpl = { unitTestSuite: unitTestSuiteService, socketIOPayload: socketIOPayloadService, socketIORequest: socketIORequestService, + socketIORequestMeta: socketIORequestMetaService, socketIOResponse: socketIOResponseService, webSocketPayload: webSocketPayloadService, webSocketRequest: webSocketRequestService, + webSocketRequestMeta: webSocketRequestMetaService, webSocketResponse: webSocketResponseService, } satisfies Record Promise>>; diff --git a/packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts b/packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts new file mode 100644 index 0000000000..cd01953ff1 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/services/socket-io-request-meta.ts @@ -0,0 +1,59 @@ +import type { SocketIORequestMeta } from '~/insomnia-data'; +import { database as db, models } from '~/insomnia-data'; + +const { type } = models.socketIORequestMeta; +const { isSocketIORequestId } = models.socketIORequest; + +function expectParentToBeSocketIORequest(parentId: string | null) { + if (!isSocketIORequestId(parentId)) { + throw new Error('Expected the parent of SocketIORequestMeta to be a SocketIORequest'); + } +} + +export function create(patch: Partial = {}) { + if (!patch.parentId) { + throw new Error('New SocketIORequestMeta missing `parentId`'); + } + + expectParentToBeSocketIORequest(patch.parentId); + return db.docCreate(type, patch); +} + +export function update(requestMeta: SocketIORequestMeta, patch: Partial) { + expectParentToBeSocketIORequest(patch.parentId || requestMeta.parentId); + return db.docUpdate(requestMeta, patch); +} + +export function getByParentId(parentId: string) { + expectParentToBeSocketIORequest(parentId); + return db.findOne(type, { parentId }); +} + +export async function getOrCreateByParentId(parentId: string) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return requestMeta; + } + + return create({ parentId }); +} + +export async function updateOrCreateByParentId(parentId: string, patch: Partial) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return update(requestMeta, patch); + } + const newPatch = Object.assign( + { + parentId, + }, + patch, + ); + return create(newPatch); +} + +export function all() { + return db.find(type); +} diff --git a/packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts b/packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts new file mode 100644 index 0000000000..02fc170c8c --- /dev/null +++ b/packages/insomnia/src/insomnia-data/node-src/services/websocket-request-meta.ts @@ -0,0 +1,59 @@ +import type { WebSocketRequestMeta } from '~/insomnia-data'; +import { database as db, models } from '~/insomnia-data'; + +const { type } = models.webSocketRequestMeta; +const { isWebSocketRequestId } = models.webSocketRequest; + +function expectParentToBeWebSocketRequest(parentId: string | null) { + if (!isWebSocketRequestId(parentId)) { + throw new Error('Expected the parent of WebSocketRequestMeta to be a WebSocketRequest'); + } +} + +export function create(patch: Partial = {}) { + if (!patch.parentId) { + throw new Error('New WebSocketRequestMeta missing `parentId`'); + } + + expectParentToBeWebSocketRequest(patch.parentId); + return db.docCreate(type, patch); +} + +export function update(requestMeta: WebSocketRequestMeta, patch: Partial) { + expectParentToBeWebSocketRequest(patch.parentId || requestMeta.parentId); + return db.docUpdate(requestMeta, patch); +} + +export function getByParentId(parentId: string) { + expectParentToBeWebSocketRequest(parentId); + return db.findOne(type, { parentId }); +} + +export async function getOrCreateByParentId(parentId: string) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return requestMeta; + } + + return create({ parentId }); +} + +export async function updateOrCreateByParentId(parentId: string, patch: Partial) { + const requestMeta = await getByParentId(parentId); + + if (requestMeta) { + return update(requestMeta, patch); + } + const newPatch = Object.assign( + { + parentId, + }, + patch, + ); + return create(newPatch); +} + +export function all() { + return db.find(type); +} diff --git a/packages/insomnia/src/insomnia-data/src/models/db-models.ts b/packages/insomnia/src/insomnia-data/src/models/db-models.ts index b08c9eddf1..cfacae5533 100644 --- a/packages/insomnia/src/insomnia-data/src/models/db-models.ts +++ b/packages/insomnia/src/insomnia-data/src/models/db-models.ts @@ -29,6 +29,7 @@ export * as settings from './settings'; export * as socketIOPayload from './socket-io-payload'; export * as socketIORequest from './socket-io-request'; export * as socketIOResponse from './socket-io-response'; +export * as socketIORequestMeta from './socket-io-request-meta'; export * as stats from './stats'; export * as unitTest from './unit-test'; export * as unitTestResult from './unit-test-result'; @@ -37,5 +38,6 @@ export * as userSession from './user-session'; export * as webSocketPayload from './websocket-payload'; export * as webSocketRequest from './websocket-request'; export * as webSocketResponse from './websocket-response'; +export * as webSocketRequestMeta from './websocket-request-meta'; export * as workspace from './workspace'; export * as workspaceMeta from './workspace-meta'; diff --git a/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts b/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts new file mode 100644 index 0000000000..2804cac343 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/src/models/socket-io-request-meta.ts @@ -0,0 +1,26 @@ +import type { BaseModel } from '~/models/types'; + +export const name = 'Socket.IO Request Meta'; + +export const type = 'SocketIORequestMeta'; + +export const prefix = 'socketio-req-meta'; + +export const canDuplicate = false; + +export const canSync = false; + +interface BaseSocketIORequestMeta { + pinned: boolean; +} + +export type SocketIORequestMeta = BaseModel & BaseSocketIORequestMeta; + +export const isSocketIORequestMeta = (model: Pick): model is SocketIORequestMeta => + model.type === type; + +export function init() { + return { + pinned: false, + }; +} diff --git a/packages/insomnia/src/insomnia-data/src/models/types.ts b/packages/insomnia/src/insomnia-data/src/models/types.ts index 0fe725b7e3..f411200d6d 100644 --- a/packages/insomnia/src/insomnia-data/src/models/types.ts +++ b/packages/insomnia/src/insomnia-data/src/models/types.ts @@ -92,6 +92,8 @@ export type { UnitTestSuite } from './unit-test-suite'; export type { SocketIOPayload } from './socket-io-payload'; export type { BaseSocketIORequest, SocketIOEventListener, SocketIORequest } from './socket-io-request'; export type { SocketIOResponse } from './socket-io-response'; +export type { SocketIORequestMeta } from './socket-io-request-meta'; export type { WebSocketPayload } from './websocket-payload'; export type { BaseWebSocketRequest, WebSocketRequest } from './websocket-request'; export type { WebSocketResponse } from './websocket-response'; +export type { WebSocketRequestMeta } from './websocket-request-meta'; diff --git a/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts b/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts new file mode 100644 index 0000000000..ead0dc9e67 --- /dev/null +++ b/packages/insomnia/src/insomnia-data/src/models/websocket-request-meta.ts @@ -0,0 +1,26 @@ +import type { BaseModel } from '~/models/types'; + +export const name = 'WebSocket Request Meta'; + +export const type = 'WebSocketRequestMeta'; + +export const prefix = 'ws-req-meta'; + +export const canDuplicate = false; + +export const canSync = false; + +interface BaseWebSocketRequestMeta { + pinned: boolean; +} + +export type WebSocketRequestMeta = BaseModel & BaseWebSocketRequestMeta; + +export const isWebSocketRequestMeta = (model: Pick): model is WebSocketRequestMeta => + model.type === type; + +export function init() { + return { + pinned: false, + }; +} diff --git a/packages/insomnia/src/models/index.ts b/packages/insomnia/src/models/index.ts index 09fe861ef3..df988a1dd5 100644 --- a/packages/insomnia/src/models/index.ts +++ b/packages/insomnia/src/models/index.ts @@ -36,8 +36,10 @@ export const workspaceMeta = models.workspaceMeta; export const webSocketPayload = models.webSocketPayload; export const webSocketRequest = models.webSocketRequest; export const webSocketResponse = models.webSocketResponse; +export const webSocketRequestMeta = models.webSocketRequestMeta; export const socketIORequest = models.socketIORequest; export const socketIOPayload = models.socketIOPayload; +export const socketIORequestMeta = models.socketIORequestMeta; export const socketIOResponse = models.socketIOResponse; export * as organization from './organization'; export const userSession = models.userSession; diff --git a/packages/insomnia/src/models/types.ts b/packages/insomnia/src/models/types.ts index 23a7546726..253a48e490 100644 --- a/packages/insomnia/src/models/types.ts +++ b/packages/insomnia/src/models/types.ts @@ -27,6 +27,7 @@ export type AllTypes = | 'SocketIOPayload' | 'SocketIORequest' | 'SocketIOResponse' + | 'SocketIORequestMeta' | 'Stats' | 'UnitTest' | 'UnitTestResult' @@ -35,6 +36,7 @@ export type AllTypes = | 'WebSocketPayload' | 'WebSocketRequest' | 'WebSocketResponse' + | 'WebSocketRequestMeta' | 'McpRequest' | 'McpResponse' | 'McpPayload' diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 2626463580..c24db54da6 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -1,6 +1,7 @@ import { getLearningFeature } from 'insomnia-api'; +import { useEffect, useRef } from 'react'; import { Button, Heading } from 'react-aria-components'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { type ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { href, Outlet, redirect, useParams, useRouteLoaderData } from 'react-router'; import * as reactUse from 'react-use'; @@ -19,6 +20,7 @@ import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizatio import { ScratchPadTutorialPanel } from '~/ui/components/panes/scratchpad-tutorial-pane'; import { ProjectNavigationSidebar } from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar'; import { SyncBar } from '~/ui/components/sidebar/sync-bar'; +import uiEventBus, { TOGGLE_PROJECT_SIDEBAR } from '~/ui/event-bus'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; @@ -135,6 +137,25 @@ const Component = ({ loaderData }: Route.ComponentProps) => { const { storagePromise } = storageRuleFetcher.data || {}; const [storageRules = DEFAULT_STORAGE_RULES] = useLoaderDeferData(storagePromise, organizationId); const [learningFeature] = useLoaderDeferData(learningFeaturePromise); + const sidebarPanelRef = useRef(null); + const [isSidebarCollapsed] = reactUse.useLocalStorage('project-navigation-collapsed', false); + const isSidebarCollapsedRef = useRef(isSidebarCollapsed); + + useEffect(() => { + if (isSidebarCollapsedRef.current) { + sidebarPanelRef.current?.collapse(); + } else { + sidebarPanelRef.current?.expand(); + } + + return uiEventBus.on(TOGGLE_PROJECT_SIDEBAR, (collapsed: boolean) => { + if (collapsed) { + sidebarPanelRef.current?.collapse(); + } else { + sidebarPanelRef.current?.expand(); + } + }); + }, []); const { features } = useOrganizationPermissions(); @@ -149,6 +170,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { direction="horizontal" > ; + const patch = (await request.json()) as Partial< + RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta + >; if (models.grpcRequest.isGrpcRequestId(requestId)) { await services.grpcRequestMeta.updateOrCreateByParentId(requestId, patch); return null; } + if (models.webSocketRequest.isWebSocketRequestId(requestId)) { + await services.webSocketRequestMeta.updateOrCreateByParentId(requestId, patch); + return null; + } + if (models.socketIORequest.isSocketIORequestId(requestId)) { + await services.socketIORequestMeta.updateOrCreateByParentId(requestId, patch); + return null; + } await services.requestMeta.updateOrCreateByParentId(requestId, patch); return null; } @@ -33,7 +43,7 @@ export const useRequestUpdateMetaActionFetcher = createFetcherSubmitHook( projectId: string; workspaceId: string; requestId: string; - patch: Partial; + patch: Partial; }) => { const url = href( '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId/update-meta', diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx index ad834185d4..4359150fc2 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx @@ -19,7 +19,9 @@ import type { RequestGroupMeta, RequestMeta, SocketIORequest, + SocketIORequestMeta, WebSocketRequest, + WebSocketRequestMeta, Workspace, WorkspaceMeta, } from '~/insomnia-data'; @@ -178,7 +180,18 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) const grpcRequestMetas = await database.find(models.grpcRequestMeta.type, { parentId: { $in: grpcReqs.map(r => r._id) }, }); - const grpcAndRequestMetas = [...requestMetas, ...grpcRequestMetas] as (RequestMeta | GrpcRequestMeta)[]; + const webSocketRequestMetas = await database.find(models.webSocketRequestMeta.type, { + parentId: { $in: wsReqs.map(r => r._id) }, + }); + const socketIORequestMetas = await database.find(models.socketIORequestMeta.type, { + parentId: { $in: socketIORequests.map(r => r._id) }, + }); + const allRequestMetas = [...requestMetas, ...grpcRequestMetas, ...webSocketRequestMetas, ...socketIORequestMetas] as ( + | RequestMeta + | GrpcRequestMeta + | WebSocketRequestMeta + | SocketIORequestMeta + )[]; const requestGroupMetas = (await database.find(models.requestGroupMeta.type, { parentId: { $in: listOfParentIds }, })) as RequestGroupMeta[]; @@ -202,7 +215,7 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) levelReqs.sort(sortFunction).map(async (doc): Promise => { const hidden = parentIsCollapsed; - const pinned = (!isRequestGroup(doc) && grpcAndRequestMetas.find(m => m.parentId === doc._id)?.pinned) || false; + const pinned = (!isRequestGroup(doc) && allRequestMetas.find(m => m.parentId === doc._id)?.pinned) || false; const collapsed = parentIsCollapsed || (isRequestGroup(doc) && requestGroupMetas.find(m => m.parentId === doc._id)?.collapsed) || diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index 4ca1fef1e6..7d83d4bc2a 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -1,6 +1,6 @@ import { type Billing, type CurrentPlan, type FeatureList, type Organization, type UserProfile } from 'insomnia-api'; import React, { Fragment, useCallback, useEffect, useState } from 'react'; -import { Button, Link, Tooltip, TooltipTrigger } from 'react-aria-components'; +import { Button, Link, ToggleButton, Tooltip, TooltipTrigger } from 'react-aria-components'; import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRouteLoaderData } from 'react-router'; import * as reactUse from 'react-use'; @@ -27,8 +27,8 @@ import { OrganizationSelect } from '~/ui/components/project/organization-select' import { InsomniaEventStreamProvider } from '~/ui/context/app/insomnia-event-stream-context'; import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context'; import { RunnerProvider } from '~/ui/context/app/runner-context'; +import uiEventBus, { TOGGLE_PROJECT_SIDEBAR } from '~/ui/event-bus'; import { useCloseConnection } from '~/ui/hooks/use-close-connection'; -import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { sortOrganizations } from '~/ui/organization-utils'; import type { AsyncTask } from '~/utils/router'; @@ -150,8 +150,7 @@ const NetworkAndSyncIndicator = ({ asyncTaskStatus, settings, sync }: IndicatorP const Component = ({ loaderData }: Route.ComponentProps) => { const { organizations, user, currentPlan } = loaderData; - const { userSession, settings } = useRootLoaderData()!; - const { billing } = useOrganizationPermissions(); + const { settings } = useRootLoaderData()!; const workspaceData = useWorkspaceLoaderData(); @@ -214,6 +213,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { }); const [isMinimal, setIsMinimal] = reactUse.useLocalStorage('isMinimal', false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = reactUse.useLocalStorage('project-navigation-collapsed', false); return ( @@ -221,58 +221,31 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
- {!isMinimal && ( -
-
-
- -
- {!isScratchPad && ( - { - window.main.trackSegmentEvent({ event: SegmentEvent.organizationSwitched }); - navigate(`/organization/${id}`); - }} - currentPlan={currentPlan} - isScratchpadWorkspace={!!isScratchpadWorkspace} - /> - )} +
+
+
+ +
+ {!isScratchPad && ( + { + window.main.trackSegmentEvent({ event: SegmentEvent.organizationSwitched }); + navigate(`/organization/${id}`); + }} + currentPlan={currentPlan} + isScratchpadWorkspace={!!isScratchpadWorkspace} + /> + )} - {!user ? : null} -
- -
- {user ? ( - - - - - - - ) : ( - - - Login - - - Sign up for free - - - )} -
-
- )} + {!user ? : null} +
+ +
+
@@ -281,6 +254,90 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
+ + { + setIsSidebarCollapsed(!value); + uiEventBus.emit(TOGGLE_PROJECT_SIDEBAR, !value); + }} + isSelected={!isSidebarCollapsed} + > + {({ isSelected }) => { + return ( + + {isSelected ? ( + + ) : ( + + )} + + ); + }} + + + Toggle sidebar + + + + { + setIsMinimal(!flag); + window.main.trackSegmentEvent({ + event: SegmentEvent.statusbarTopbarToggled, + properties: { + status: !flag ? 'minimal' : 'expanded', + }, + }); + }} + isSelected={!isMinimal} + > + {({ isSelected }) => { + return ( + + {isSelected ? ( + + ) : ( + + )} + + ); + }} + + + Toggle header + +
- {isMinimal && ( -
- {user ? ( - - - - - - - ) : ( - - - Login - - - Sign up for free - - - )} -
- )}
+
+ {user ? ( + + + + + + + ) : ( + + + Login + + + Sign up for free + + + )} +
diff --git a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx index 9af7e77e9a..5be0008b23 100644 --- a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx @@ -176,7 +176,7 @@ export const ProjectDropdown: FC = ({ project, organizationId, storageRul // Use this because createNewWorkspace action will navigate to the newly create workspace page setNewWorkspaceModalState(prev => prev && { ...prev, isOpen: false }); } - }, [newWorkspaceModalState?.isOpen, workspaceId]); + }, [workspaceId]); return ( diff --git a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx index bdb05d9261..ec6b29f997 100644 --- a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx @@ -62,14 +62,15 @@ export const RequestActionsDropdown = ({ onOpenChange, onRename, }: Props) => { + const workspaceId = activeWorkspace._id; + const projectId = activeProject._id; const { settings } = useRootLoaderData()!; - const patchRequestMeta = useRequestMetaPatcher(); + const patchRequestMeta = useRequestMetaPatcher(workspaceId); const { hotKeyRegistry } = settings; const [actionPlugins, setActionPlugins] = useState([]); const duplicateRequestFetcher = useRequestDuplicateActionFetcher(); const deleteRequestFetcher = useRequestDeleteActionFetcher(); - const workspaceId = activeWorkspace._id; - const projectId = activeProject._id; + const { organizationId } = useParams() as { organizationId: string; }; @@ -333,7 +334,7 @@ export const RequestActionsDropdown = ({ diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx index 034252955f..1ff5183e7e 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx @@ -21,6 +21,7 @@ import { Popover, Radio, RadioGroup, + SubmenuTrigger, } from 'react-aria-components'; import { href } from 'react-router'; @@ -33,7 +34,7 @@ import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organiza import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import type { CreateRequestType } from '~/ui/hooks/use-request'; -import { getProductName } from '../../../common/constants'; +import { getProductName, SORT_ORDERS, type SortOrder, sortOrderName } from '../../../common/constants'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import type { PlatformKeyCombinations } from '../../../common/settings'; import { SegmentEvent } from '../../analytics'; @@ -51,6 +52,8 @@ interface Props { workspace: Workspace; project: Project; organizationId: string; + sortOrder?: SortOrder; + onSortOrderChange: (newSortOrder: SortOrder) => void; } interface ActionItem { @@ -60,6 +63,8 @@ interface ActionItem { hint?: PlatformKeyCombinations; action: () => void; className?: string; + hasSubmenu?: boolean; + submenuItems?: Omit[]; } interface ActionSection { @@ -69,7 +74,13 @@ interface ActionSection { items: ActionItem[]; } -export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: Props) => { +export const SidebarWorkspaceDropdown = ({ + workspace, + project, + organizationId, + sortOrder, + onSortOrderChange, +}: Props) => { const projectId = project._id; const workspaceId = workspace._id; @@ -230,6 +241,22 @@ export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: }, ] : []), + ...(models.workspace.isCollection(workspace) + ? [ + { + id: 'Sort', + name: 'Sort', + icon: 'sort' as IconName, + action: () => {}, + hasSubmenu: true, + submenuItems: SORT_ORDERS.map(order => ({ + id: order, + name: sortOrderName[order], + action: () => onSortOrderChange(order), + })), + }, + ] + : []), { id: 'Rename', name: 'Rename', @@ -308,7 +335,7 @@ export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: ?.action() } items={allSections} - className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" + className="max-h-128 min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" > {section => ( @@ -316,18 +343,52 @@ export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: {section.name} - {item => ( - - - {item.name} - {item.hint && } - - )} + {item => + !item.hasSubmenu ? ( + + + {item.name} + {item.hint && } + + ) : ( + + + + {item.name} + + + + item.submenuItems?.find(s => s.id === key)?.action()} + items={item.submenuItems} + className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" + > + {subItem => ( + + {subItem.name} + {sortOrder === subItem.id && ( + + )} + + )} + + + + ) + } )} diff --git a/packages/insomnia/src/ui/components/kong-logo.tsx b/packages/insomnia/src/ui/components/kong-logo.tsx new file mode 100644 index 0000000000..bb1ca66c3a --- /dev/null +++ b/packages/insomnia/src/ui/components/kong-logo.tsx @@ -0,0 +1,27 @@ +export const KongLogo = ({ ...props }: {} & React.SVGProps) => ( + + + + + + + + + + + + + +); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx new file mode 100644 index 0000000000..1810f15b83 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx @@ -0,0 +1,261 @@ +import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core'; +import type { StorageRules } from 'insomnia-api'; +import { useState } from 'react'; +import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'; + +import type { WorkspaceScope } from '~/insomnia-data'; +import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new'; +import { useRequestGroupNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request-group.new'; +import { showModal } from '~/ui/components/modals'; +import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal'; +import { PromptModal } from '~/ui/components/modals/prompt-modal'; +import type { CreateRequestType } from '~/ui/hooks/use-request'; + +import { Icon } from '../../icon'; +import { GUIDE_LINE_CSS, ROW_CLASS } from './project-navigation-sidebar-utils'; +import type { EmptyNodeFlatItem } from './types'; + +interface EmptyNodeProps { + item: EmptyNodeFlatItem; + storageRules: StorageRules; +} + +interface ActionItem { + id: string; + name: string; + icon: IconProp; + action: () => void; +} + +export const EmptyNode = ({ item, storageRules }: EmptyNodeProps) => { + const { organizationId, project, workspace, requestGroup, level = 0, kind } = item; + const [newWorkspaceModalState, setNewWorkspaceModalState] = useState<{ + scope: WorkspaceScope; + isOpen: boolean; + } | null>({ + scope: 'collection', + isOpen: false, + }); + const createNewCollection = () => setNewWorkspaceModalState({ scope: 'collection', isOpen: true }); + const createNewDocument = () => setNewWorkspaceModalState({ scope: 'design', isOpen: true }); + const createNewMockServer = () => setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true }); + const createNewGlobalEnvironment = () => setNewWorkspaceModalState({ scope: 'environment', isOpen: true }); + const createNewMcpClient = () => setNewWorkspaceModalState({ scope: 'mcp', isOpen: true }); + + const newRequestFetcher = useRequestNewActionFetcher(); + const newRequestGroupFetcher = useRequestGroupNewActionFetcher(); + const parentId = requestGroup?._id || workspace?._id || project._id; + + const createRequest = ({ requestType }: { requestType: CreateRequestType }) => { + if (!workspace) return; + newRequestFetcher.submit({ + organizationId, + projectId: project._id, + workspaceId: workspace._id, + requestType, + parentId, + }); + }; + + const createFolder = () => { + if (!workspace) return; + showModal(PromptModal, { + title: 'New Folder', + defaultValue: 'My Folder', + submitName: 'Create', + label: 'Name', + selectText: true, + onComplete: (name: string) => + newRequestGroupFetcher.submit({ + organizationId, + projectId: project._id, + workspaceId: workspace._id, + parentId, + name, + }), + }); + }; + + const createRequestActionItems: ActionItem[] = [ + { + id: 'HTTP', + name: 'HTTP Request', + icon: 'plus-circle', + action: () => + createRequest({ + requestType: 'HTTP', + }), + }, + { + id: 'Event Stream', + name: 'Event Stream Request (SSE)', + icon: 'plus-circle', + action: () => + createRequest({ + requestType: 'Event Stream', + }), + }, + { + id: 'GraphQL Request', + name: 'GraphQL Request', + icon: 'plus-circle', + action: () => + createRequest({ + requestType: 'GraphQL', + }), + }, + { + id: 'gRPC Request', + name: 'gRPC Request', + icon: 'plus-circle', + action: () => + createRequest({ + requestType: 'gRPC', + }), + }, + { + id: 'WebSocket Request', + name: 'WebSocket Request', + icon: 'plus-circle', + action: () => + createRequest({ + requestType: 'WebSocket', + }), + }, + { + id: 'Socket.IO Request', + name: 'Socket.IO Request', + icon: 'plus-circle', + action: () => + createRequest({ + requestType: 'SocketIO', + }), + }, + { + id: 'New Folder', + name: 'New Folder', + icon: 'folder', + action: createFolder, + }, + ]; + + const createInProjectActionList: ActionItem[] = [ + { + id: 'new-collection', + name: 'Request collection', + icon: 'bars', + action: createNewCollection, + }, + { + id: 'new-document', + name: 'Design document', + icon: 'file', + action: createNewDocument, + }, + { + id: 'new-mcp-client', + name: 'MCP Client', + icon: ['fac', 'mcp'] as unknown as IconProp, + action: createNewMcpClient, + }, + + { + id: 'new-mock-server', + name: 'Mock Server', + icon: 'server' as IconName, + action: createNewMockServer, + }, + + { + id: 'new-environment', + name: 'Environment', + icon: 'code', + action: createNewGlobalEnvironment, + }, + ]; + + const getLabel = () => { + switch (kind) { + case 'emptyProject': { + return 'Project is empty'; + } + case 'emptyCollection': { + return 'Collection is empty'; + } + case 'emptyFolder': { + return 'Folder is empty'; + } + default: { + return ''; + } + } + }; + + const paddingLeft = kind === 'emptyProject' ? '2em' : `${level + 3}rem`; + + return ( +
+ + {kind !== 'emptyProject' && } + {kind === 'emptyFolder' && + Array.from({ length: level + 2 }, (_, i) => ( + + ))} + {getLabel()} + + + + { + const item = (kind === 'emptyProject' ? createInProjectActionList : createRequestActionItems).find( + item => item.id === key, + ); + if (item) { + item.action(); + } + }} + items={kind === 'emptyProject' ? createInProjectActionList : createRequestActionItems} + className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-base shadow-lg select-none focus:outline-hidden" + > + {item => ( + + + {item.name} + + )} + + + + {newWorkspaceModalState?.isOpen && ( + { + setNewWorkspaceModalState({ + scope: newWorkspaceModalState.scope, + isOpen, + }); + }} + /> + )} +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts index 72697436d1..32ba5c0efa 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts @@ -1,6 +1,6 @@ import { database } from '~/common/database'; import { fuzzyMatchAll } from '~/common/misc'; -import { metaSortKeySort } from '~/common/sorting'; +import { sortMethodMap } from '~/common/sorting'; import type { GrpcRequest, GrpcRequestMeta, @@ -9,7 +9,9 @@ import type { RequestGroupMeta, RequestMeta, SocketIORequest, + SocketIORequestMeta, WebSocketRequest, + WebSocketRequestMeta, Workspace, } from '~/insomnia-data'; import { models } from '~/insomnia-data'; @@ -28,7 +30,7 @@ type AllRequestDoc = Request | GrpcRequest | WebSocketRequest | SocketIORequest export interface AllRequestsAndMetaInWorkspace { allRequests: AllRequestDoc[]; - allRequestMetas: (RequestMeta | GrpcRequestMeta)[]; + allRequestMetas: (RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta)[]; requestGroupMetas: RequestGroupMeta[]; } @@ -66,11 +68,15 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) { const requestGroupToWorkspaceId = new Map(); const requestToWorkspaceId = new Map(); const grpcRequestToWorkspaceId = new Map(); + const wsRequestToWorkspaceId = new Map(); + const socketIORequestToWorkspaceId = new Map(); // Initialize the map with workspace IDs workspaceIds.forEach(workspaceId => { requestGroupToWorkspaceId.set(workspaceId, workspaceId); requestToWorkspaceId.set(workspaceId, workspaceId); grpcRequestToWorkspaceId.set(workspaceId, workspaceId); + wsRequestToWorkspaceId.set(workspaceId, workspaceId); + socketIORequestToWorkspaceId.set(workspaceId, workspaceId); allRequestsAndMetaByWorkspaceId.set(workspaceId, { allRequests: [], allRequestMetas: [], requestGroupMetas: [] }); }); @@ -105,7 +111,7 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) { const allRequests = [...reqs, ...allRequestGroups, ...grpcReqs, ...wsReqs, ...socketIOReqs] as AllRequestDoc[]; - const [requestMetas, grpcRequestMetas, requestGroupMetas] = await Promise.all([ + const [requestMetas, grpcRequestMetas, requestGroupMetas, wsRequestMetas, socketIORequestMetas] = await Promise.all([ database.find(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }), database.find(models.grpcRequestMeta.type, { parentId: { $in: grpcReqs.map(r => r._id) }, @@ -113,17 +119,29 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) { database.find(models.requestGroupMeta.type, { parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) }, }), + database.find(models.webSocketRequestMeta.type, { + parentId: { $in: wsReqs.map(r => r._id) }, + }), + database.find(models.socketIORequestMeta.type, { + parentId: { $in: socketIOReqs.map(r => r._id) }, + }), ]); - const allRequestMetas = [...requestMetas, ...grpcRequestMetas] as (RequestMeta | GrpcRequestMeta)[]; + const allRequestMetas = [...requestMetas, ...grpcRequestMetas, ...wsRequestMetas, ...socketIORequestMetas]; + // Associate requests with their workspace IDs and group request metas by workspace ID allRequests.forEach(request => { const { parentId, _id: requestId } = request; const workspaceId = requestGroupToWorkspaceId.get(parentId); if (workspaceId) { + // Track which workspace this request belongs to if (models.grpcRequest.isGrpcRequest(request)) { grpcRequestToWorkspaceId.set(requestId, workspaceId); } else if (models.request.isRequest(request)) { requestToWorkspaceId.set(requestId, workspaceId); + } else if (models.webSocketRequest.isWebSocketRequest(request)) { + wsRequestToWorkspaceId.set(requestId, workspaceId); + } else if (models.socketIORequest.isSocketIORequest(request)) { + socketIORequestToWorkspaceId.set(requestId, workspaceId); } const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId); if (workspaceData) { @@ -131,7 +149,7 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) { } } }); - + // Build map of requestGroupMetas by workspace ID requestGroupMetas.forEach(requestGroupMeta => { const workspaceId = requestGroupToWorkspaceId.get(requestGroupMeta.parentId); if (workspaceId) { @@ -148,6 +166,10 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) { workspaceId = requestToWorkspaceId.get(requestOrGrpcRequestId); } else if (models.grpcRequest.isGrpcRequestId(requestOrGrpcRequestId)) { workspaceId = grpcRequestToWorkspaceId.get(requestOrGrpcRequestId); + } else if (models.webSocketRequest.isWebSocketRequestId(requestOrGrpcRequestId)) { + workspaceId = wsRequestToWorkspaceId.get(requestOrGrpcRequestId); + } else if (models.socketIORequest.isSocketIORequestId(requestOrGrpcRequestId)) { + workspaceId = socketIORequestToWorkspaceId.get(requestOrGrpcRequestId); } if (workspaceId) { const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId); @@ -164,6 +186,7 @@ export function flattenCollectionChildren( workspaceId: string, parentIsCollapsed: boolean, { allRequests, allRequestMetas, requestGroupMetas }: AllRequestsAndMetaInWorkspace, + sortOrder: keyof typeof sortMethodMap = 'type-manual', ): Child[] { const { isRequestGroup } = models.requestGroup; const collection: Child[] = []; @@ -178,8 +201,8 @@ export function flattenCollectionChildren( requestsByParentId.set(req.parentId, [req]); } } - - const rootRequests = (requestsByParentId.get(workspaceId) || []).sort(metaSortKeySort); + const sortFunction = sortMethodMap[sortOrder]; + const rootRequests = (requestsByParentId.get(workspaceId) || []).sort(sortFunction); const stack: { doc: AllRequestDoc; level: number; parentIsCollapsed: boolean; ancestors: string[] }[] = [ ...rootRequests, ] @@ -204,7 +227,7 @@ export function flattenCollectionChildren( // if it's a request group, add its children to the stack if (isRequestGroup(doc)) { - const childDocs = (requestsByParentId.get(doc._id) || []).sort(metaSortKeySort); + const childDocs = (requestsByParentId.get(doc._id) || []).sort(sortFunction); const childAncestors = [...ancestors, doc._id]; for (let i = childDocs.length - 1; i >= 0; i--) { stack.push({ doc: childDocs[i], level: level + 1, parentIsCollapsed: collapsed, ancestors: childAncestors }); @@ -257,7 +280,7 @@ export function filterCollection(collection: Child[], filter: string): Child[] { // Common tailwind classes export const ROW_CLASS = - 'relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-4'; + 'relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-[rgba(var(--color-font-rgb),0.8)] outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-4'; export const ACTIVE_BORDER_CLASS = 'absolute top-0 left-0 h-full w-0.5 bg-transparent transition-colors group-aria-selected:bg-(--color-surprise)'; @@ -265,7 +288,7 @@ export const GUIDE_LINE_CSS = 'absolute inset-y-0 w-px bg-transparent transition // for toggle button export const TOGGLE_BTN_CLASS = - 'flex shrink-0 items-center justify-center text-sm text-(--hl) hover:text-(--color-font) focus:outline-none w-4 h-4'; + 'flex shrink-0 items-center justify-center text-base text-[rgba(var(--color-font-rgb),0.8)] hover:text-(--color-font) focus:outline-none w-4 h-4'; export const ICON_CLASS = 'h-3 w-3 shrink-0'; export const INDENT_PX = 16; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index f0a3b08d80..462bc9f605 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -5,22 +5,25 @@ import { Button, GridList, GridListItem, Input, SearchField } from 'react-aria-c import { useNavigate, useParams, useSearchParams } from 'react-router'; import * as reactUse from 'react-use'; +import type { SortOrder } from '~/common/constants'; import { fuzzyMatchAll } from '~/common/misc'; import { getAllRemoteBackendProjectsByProjectId, getUnsyncedRemoteWorkspaces, type InsomniaFile, } from '~/common/project'; -import type { Workspace } from '~/insomnia-data'; +import type { RequestGroup, Workspace } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import type { SyncResult } from '~/konnect/sync'; import { useRootLoaderData } from '~/root'; import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId'; import { SegmentEvent } from '~/ui/analytics'; +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 { ProjectModal } from '~/ui/components/modals/project-modal'; +import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node'; import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node'; import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; @@ -38,8 +41,8 @@ import { getWorkspacesByProjectIds, } from './project-navigation-sidebar-utils'; import { ProjectNode } from './project-node'; -import { RequestNode } from './request-node'; -import type { FlatItem } from './types'; +import { PinnedHeaderNode, RequestNode } from './request-node'; +import type { EmptyNodeFlatItem, FlatItem } from './types'; import { useProjectNavigationSidebarNavigation } from './use-project-navigation-sidebar-navigation'; import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop'; import { WorkspaceNode } from './workspace-node'; @@ -101,6 +104,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P const tabNavigate = useTabNavigate(); const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false); + const [collectionSortOrders, setCollectionSortOrders] = useState>({}); const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState>(new Map()); const [projectNavigationSidebarFilter, setProjectNavigationSidebarFilter] = reactUse.useLocalStorage( `${organizationId}:project-navigation-sidebar-filter`, @@ -345,8 +349,19 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P const unsyncedWorkspaces = models.project.isRemoteProject(project) ? getUnsyncedRemoteWorkspaces(unsyncedFilesByProjectId.get(projectId) || [], sortedWorkspaces) : []; + const allWorkspaces = [...sortedWorkspaces, ...unsyncedWorkspaces]; + // If there is no workspace under the project, show an empty workspace node + if (allWorkspaces.length === 0) { + items.push({ + kind: 'emptyProject', + organizationId, + project, + hidden: isProjectCollapsed, + doc: { _id: `empty-project-${projectId}`, name: '' }, + }); + } - for (const workspace of [...sortedWorkspaces, ...unsyncedWorkspaces]) { + for (const workspace of allWorkspaces) { if (workspace.scope === 'unsynced') { items.push({ kind: 'unsyncedWorkspace', @@ -382,8 +397,14 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P const shouldHideCollectionChildren = isWorkspaceCollapsed || isProjectCollapsed; let collectionChildren = (!shouldHideCollectionChildren || !!projectNavigationSidebarFilter) && allRequestsAndMetaInWorkspace - ? flattenCollectionChildren(workspaceId, shouldHideCollectionChildren, allRequestsAndMetaInWorkspace) + ? flattenCollectionChildren( + workspaceId, + shouldHideCollectionChildren, + allRequestsAndMetaInWorkspace, + collectionSortOrders[workspaceId] || 'type-manual', + ) : []; + const pinnedCollectionChildren = collectionChildren.filter(child => child.pinned); if (projectNavigationSidebarFilter) { // apply filter to collection children first @@ -402,6 +423,32 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P items.find(i => i.kind === 'workspace' && i.doc._id === workspaceId)!.hidden = shouldHide; } + if (pinnedCollectionChildren.length > 0) { + items.push({ + kind: 'pinnedHeader', + hidden: false, + doc: { _id: `${workspaceId}-pinned-header`, name: 'Pinned' }, + }); + } + + pinnedCollectionChildren.forEach((child, idx) => { + items.push({ + kind: 'pinnedRequest', + organizationId, + project: project, + workspace: workspace as Workspace, + children: child.children, + ancestors: child.ancestors, + doc: child.doc, + collapsed: child.collapsed, + hidden: child.hidden, + level: child.level, + pinned: child.pinned, + isFirstPinned: idx === 0, + isLastPinned: idx === pinnedCollectionChildren.length - 1, + }); + }); + collectionChildren.forEach(child => { items.push({ kind: 'collectionChild', @@ -416,7 +463,35 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P level: child.level, pinned: child.pinned, }); + if ( + models.requestGroup.isRequestGroupId(child.doc._id) && + child.children?.length === 0 && + !projectNavigationSidebarFilter + ) { + // If there is a request group with no children, add an empty folder node + items.push({ + kind: 'emptyFolder', + organizationId, + project, + workspace: workspace as Workspace, + requestGroup: child.doc as RequestGroup, + doc: { _id: `empty-folder-${child.doc._id}`, name: '' }, + hidden: child.collapsed, + level: child.level, + }); + } }); + + if (collectionChildren.length === 0 && !shouldHideCollectionChildren && !projectNavigationSidebarFilter) { + items.push({ + kind: 'emptyCollection', + organizationId, + project: project, + workspace: workspace as Workspace, + doc: { _id: `empty-collection-${(workspace as Workspace)._id}`, name: '' }, + hidden: false, + }); + } } } @@ -435,7 +510,11 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P // If there is an active filter, expand all items to show matched results and their ancestors if (projectNavigationSidebarFilter) { - items.forEach(item => (item.collapsed = false)); + items.forEach(item => { + if ('collapsed' in item) { + item.collapsed = false; + } + }); } setFlatItems(items); @@ -448,6 +527,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P projectNavigationSidebarFilter, projectsWithPresence, unsyncedFilesByProjectId, + collectionSortOrders, ]); const toggleProjectOrWorkspace = useCallback( @@ -518,48 +598,64 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P const toggledChildren: { id: string; parentIsCollapsed: boolean }[] = []; return nextFlatItems.map(item => { - if (item.kind !== 'collectionChild') { - return item; - } - - const { children, doc } = item; - // Find toggled request group and update collapsed state - if (doc._id === requestGroupId) { - // Add all children of the toggled request group to the array to update their hidden state - toggledChildren.push( - ...(children?.map(child => ({ id: child.doc._id, parentIsCollapsed: collapsed })) ?? []), - ); - - return { - ...item, - collapsed, - hidden: false, - }; - } - - const matchedToggledChild = toggledChildren.find(tc => tc.id === item.doc._id); - if (matchedToggledChild) { - const { parentIsCollapsed } = matchedToggledChild; - if (models.requestGroup.isRequestGroupId(doc._id)) { - // Add children of the toggled child request group to the array to update their hidden state - const isToggledRequestGroupCollapsed = - parentIsCollapsed || - cachedCollectionChildrenAndMetaRef.current - .get(workspace._id) - ?.requestGroupMetas.find(rgm => rgm.parentId === doc._id)?.collapsed || - false; + if (item.kind === 'collectionChild') { + const { children, doc } = item; + // Find toggled request group and update collapsed state + if (doc._id === requestGroupId) { + // Add all children of the toggled request group to the array to update their hidden state toggledChildren.push( - ...(item.children?.map(child => ({ - id: child.doc._id, - parentIsCollapsed: isToggledRequestGroupCollapsed, - })) ?? []), + ...(children?.map(child => ({ id: child.doc._id, parentIsCollapsed: collapsed })) ?? []), ); + + return { + ...item, + collapsed, + hidden: false, + }; } - return { - ...item, - hidden: parentIsCollapsed, - }; + const matchedToggledChild = toggledChildren.find(tc => tc.id === item.doc._id); + if (matchedToggledChild) { + const { parentIsCollapsed } = matchedToggledChild; + if (models.requestGroup.isRequestGroupId(doc._id)) { + // Add children of the toggled child request group to the array to update their hidden state + const isToggledRequestGroupCollapsed = + parentIsCollapsed || + cachedCollectionChildrenAndMetaRef.current + .get(workspace._id) + ?.requestGroupMetas.find(rgm => rgm.parentId === doc._id)?.collapsed || + false; + toggledChildren.push( + ...(item.children?.map(child => ({ + id: child.doc._id, + parentIsCollapsed: isToggledRequestGroupCollapsed, + })) ?? []), + ); + } + + return { + ...item, + hidden: parentIsCollapsed, + }; + } + } + + if (item.kind === 'emptyFolder') { + const parentFolder = item.requestGroup; + const parentFolderId = parentFolder?._id; + const matchedToggledChild = toggledChildren.find(tc => tc.id === parentFolderId); + // Update the emptyFolder node hidden state based on its parent request group collapsed state. + if (parentFolderId === requestGroupId) { + return { + ...item, + hidden: collapsed, + }; + } else if (matchedToggledChild) { + return { + ...item, + hidden: matchedToggledChild.parentIsCollapsed, + }; + } } return item; @@ -599,15 +695,23 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P return (
- {['projects', 'konnect'].map(tabName => ( - - ))} + {!isScratchPad && + ['projects', 'konnect'].map(tabName => ( + + ))}
{ if (e.button === 1 && item.kind === 'collectionChild') { @@ -719,7 +824,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P { withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams }, ); } - } else if (item.kind === 'collectionChild') { + } else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') { if ( routeInfo?.resourceId === docId && models.requestGroup.isRequestGroupId(docId) && @@ -753,10 +858,33 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P )} - {item.kind === 'workspace' && } + {item.kind === 'workspace' && ( + { + if (item.doc.scope === 'collection') { + setCollectionSortOrders(prev => { + const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder }; + return newCollectionSortOrders; + }); + } + }} + /> + )} + + {item.kind === 'pinnedHeader' && } {item.kind === 'collectionChild' && } + + {item.kind === 'pinnedRequest' && } + {item.kind === 'unsyncedWorkspace' && } + + {item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? ( + + ) : null} ); }} diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx index e99f31f9ec..41811f0c4a 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx @@ -38,7 +38,7 @@ export const ProjectNode = ({ item, storageRules, onToggle }: ProjectNodeProps) : 'laptop' } /> - {projectName} + {projectName}
{presence.length > 0 && } {projectId !== models.project.SCRATCHPAD_PROJECT_ID && ( diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx index be77eb44f4..c2f14442a0 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; -import { Button } from 'react-aria-components'; +import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'; +import { toKebabCase } from '~/common/misc'; import type { GrpcRequest, McpRequest, @@ -16,9 +17,12 @@ import { RequestGroupActionsDropdown } from '~/ui/components/dropdowns/request-g import { EditableInput } from '~/ui/components/editable-input'; import { showModal } from '~/ui/components/modals'; import { PromptModal } from '~/ui/components/modals/prompt-modal'; -import type { CollectionChildFlatItem } from '~/ui/components/sidebar/project-navigation-sidebar/types'; +import type { + CollectionChildFlatItem, + PinnedRequestFlatItem, +} from '~/ui/components/sidebar/project-navigation-sidebar/types'; import { getMethodShortHand, getRequestMethodShortHand } from '~/ui/components/tags/method-tag'; -import { useRequestGroupPatcher, useRequestPatcher } from '~/ui/hooks/use-request'; +import { useRequestGroupPatcher, useRequestMetaPatcher, useRequestPatcher } from '~/ui/hooks/use-request'; import { Icon } from '../../icon'; import { @@ -82,32 +86,28 @@ const getRequestNameOrFallback = ( }; interface RequestNodeProps { - item: CollectionChildFlatItem; + item: CollectionChildFlatItem | PinnedRequestFlatItem; onToggleFolder: (requestGroupIds: string[], workspace: Workspace) => void; + className?: string; } -export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => { - const { doc, level, workspace, project, collapsed } = item; +export const RequestNode = ({ item, onToggleFolder, className }: RequestNodeProps) => { + const { doc, level: requestLevel, workspace, project, collapsed, pinned, kind } = item; + const isPinnedRequest = kind === 'pinnedRequest'; + const isLastPinned = item.kind === 'pinnedRequest' && item.isLastPinned; - const patchRequest = useRequestPatcher(); - const patchGroup = useRequestGroupPatcher(); + const workspaceId = workspace._id; + const patchRequest = useRequestPatcher(workspaceId); + const patchGroup = useRequestGroupPatcher(workspaceId); + const patchRequestMeta = useRequestMetaPatcher(workspaceId); const isFolder = models.requestGroup.isRequestGroup(doc); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [isEditable, setIsEditable] = useState(false); + // Pinned requests are always shown at the top level of the sidebar, so we set their level to 0. + const level = isPinnedRequest ? 0 : requestLevel; - return ( -
- {Array.from({ length: level + 2 }, (_, i) => { - const isActive = i === level + 1; - return ( - - ); - })} - + const content = ( + <> - - {isFolder ? ( - - ) : ( - <> - - +
+ {isFolder ? : } + setIsEditable(editable)} + onSubmit={newName => { + if (models.requestGroup.isRequestGroup(doc)) { + patchGroup(doc._id, { name: newName }); + } else { + patchRequest(doc._id, { name: newName }); + } + }} + /> +
+ {!models.requestGroup.isRequestGroup(doc) && pinned && !isPinnedRequest && ( + + + + Unpin Request + + )} - setIsEditable(editable)} - onSubmit={newName => { - if (models.requestGroup.isRequestGroup(doc)) { - patchGroup(doc._id, { name: newName }); - } else { - patchRequest(doc._id, { name: newName }); - } - }} - /> {models.requestGroup.isRequestGroup(doc) && !isEditable && ( { onOpenChange={setIsContextMenuOpen} /> )} + + ); + + return ( +
+ {isPinnedRequest ? ( + <> + + + + ) : ( + Array.from({ length: level + 2 }, (_, i) => { + const isActive = i === level + 1; + return ( + + ); + }) + )} + + {isPinnedRequest ? ( +
+ {content} +
+ ) : ( + content + )} +
+ ); +}; + +export const PinnedHeaderNode = () => { + return ( +
+ + +
+ + Pinned +
); }; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts index af87b60572..4057ae78e7 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/types.ts @@ -1,5 +1,5 @@ import type { InsomniaFile } from '~/common/project'; -import type { GitRepository, Project, Workspace, WorkspaceMeta } from '~/insomnia-data'; +import type { GitRepository, Project, RequestGroup, Workspace, WorkspaceMeta } from '~/insomnia-data'; import type { BaseModel } from '~/models/types'; import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; @@ -40,7 +40,7 @@ export interface WorkspaceFlatItem extends BaseFlatItem { //unsynced workspace in clod sync project type UnsyncedWorkspaceDoc = InsomniaFile & { _id: string }; -export type UnsyncedWorkspaceFlatItem = Exclude, 'doc'> & +export type UnsyncedWorkspaceFlatItem = Omit, 'doc'> & Pick & { kind: 'unsyncedWorkspace'; doc: UnsyncedWorkspaceDoc; @@ -60,4 +60,34 @@ export interface CollectionChildFlatItem extends BaseFlatItem { pinned: boolean; } -export type FlatItem = ProjectFlatItem | WorkspaceFlatItem | CollectionChildFlatItem | UnsyncedWorkspaceFlatItem; +export interface PinnedRequestFlatItem extends Omit { + kind: 'pinnedRequest'; + isFirstPinned: boolean; + isLastPinned: boolean; +} + +export interface PinnedHeaderFlatItem { + kind: 'pinnedHeader'; + hidden: boolean; + doc: { _id: string; name: string }; +} + +export interface EmptyNodeFlatItem { + kind: 'emptyProject' | 'emptyCollection' | 'emptyFolder'; + hidden: boolean; + organizationId: string; + doc: { _id: string; name: string }; + project: ProjectWithPresence; + workspace?: Workspace; + requestGroup?: RequestGroup; + level?: number; +} + +export type FlatItem = + | ProjectFlatItem + | WorkspaceFlatItem + | CollectionChildFlatItem + | UnsyncedWorkspaceFlatItem + | PinnedRequestFlatItem + | PinnedHeaderFlatItem + | EmptyNodeFlatItem; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node.tsx index 2f2f222867..54930c3833 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Button } from 'react-aria-components'; +import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'; import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file'; import { showToast } from '~/ui/components/toast-notification'; @@ -41,24 +41,34 @@ export const UnsyncedWorkspaceNode = ({ item }: { item: UnsyncedWorkspaceFlatIte + + + + Click to fetch this file + +
); }; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx index db91ba2a1a..98543a5a23 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx @@ -1,6 +1,7 @@ import { Button } from 'react-aria-components'; -import { scopeToIconMap } from '~/common/get-workspace-label'; +import type { SortOrder } from '~/common/constants'; +import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label'; import { SidebarWorkspaceDropdown } from '~/ui/components/dropdowns/sidebar-workspace-dropdown'; import { Icon } from '../../icon'; @@ -15,10 +16,12 @@ import { type WorkspaceFlatItem } from './types'; interface WorkspaceNodeProps { item: WorkspaceFlatItem; + sortOrder: SortOrder; onToggle: (workspaceId: string) => void; + onSortOrderChange: (newSortOrder: SortOrder) => void; } -export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => { +export const WorkspaceNode = ({ item, sortOrder, onToggle, onSortOrderChange }: WorkspaceNodeProps) => { const { doc, collapsed, project, organizationId } = item; const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc; const isCollection = workspaceScope === 'collection'; @@ -35,11 +38,22 @@ export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => { {isCollection ? : null}
- - {workspaceName} +
+ +
+ + {workspaceName}
- +
); diff --git a/packages/insomnia/src/ui/event-bus.ts b/packages/insomnia/src/ui/event-bus.ts index 87e4a464f8..1cf7147c5f 100644 --- a/packages/insomnia/src/ui/event-bus.ts +++ b/packages/insomnia/src/ui/event-bus.ts @@ -3,16 +3,19 @@ type EventHandler = (...args: any[]) => void; export const OAUTH2_AUTHORIZATION_STATUS_CHANGE = 'OAUTH2_AUTHORIZATION_STATUS_CHANGE'; // This event is emitted when remote cloud sync file is changed, including project, workspace creation, deletion and update. export const CLOUD_SYNC_FILE_CHANGE = 'CLOUD_SYNC_FILE_CHANGE'; +export const TOGGLE_PROJECT_SIDEBAR = 'TOGGLE_PROJECT_SIDEBAR'; type UIEventType = | 'CLOSE_TAB' | 'CHANGE_ACTIVE_ENV' + | typeof TOGGLE_PROJECT_SIDEBAR | typeof CLOUD_SYNC_FILE_CHANGE | typeof OAUTH2_AUTHORIZATION_STATUS_CHANGE; class EventBus { private events: Record = { CLOSE_TAB: [], CHANGE_ACTIVE_ENV: [], + [TOGGLE_PROJECT_SIDEBAR]: [], [CLOUD_SYNC_FILE_CHANGE]: [], [OAUTH2_AUTHORIZATION_STATUS_CHANGE]: [], }; diff --git a/packages/insomnia/src/ui/hooks/use-request.ts b/packages/insomnia/src/ui/hooks/use-request.ts index d32ee4e75c..70aeeb6326 100644 --- a/packages/insomnia/src/ui/hooks/use-request.ts +++ b/packages/insomnia/src/ui/hooks/use-request.ts @@ -23,7 +23,7 @@ import { useRequestGroupUpdateMetaActionFetcher } from '~/routes/organization.$o import { useWorkspaceUpdateMetaActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.update-meta'; import { useSettingsUpdateActionFetcher } from '~/routes/settings.update'; -export const useRequestPatcher = () => { +export const useRequestPatcher = (requestWorkspaceId = '') => { const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; @@ -44,12 +44,13 @@ export const useRequestPatcher = () => { patch, projectId, requestId, - workspaceId, + // If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params + workspaceId: workspaceId || requestWorkspaceId, }); }; }; -export const useRequestMetaPatcher = () => { +export const useRequestMetaPatcher = (requestWorkspaceId = '') => { const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; @@ -60,14 +61,15 @@ export const useRequestMetaPatcher = () => { fetcher.submit({ organizationId, projectId, - workspaceId, + // If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params + workspaceId: workspaceId || requestWorkspaceId, requestId, patch, }); }; }; -export const useRequestGroupPatcher = () => { +export const useRequestGroupPatcher = (requestWorkspaceId = '') => { const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; @@ -78,14 +80,15 @@ export const useRequestGroupPatcher = () => { fetcher.submit({ organizationId, projectId, - workspaceId, + // If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params + workspaceId: workspaceId || requestWorkspaceId, requestGroupId, patch, }); }; }; -export const useRequestGroupMetaPatcher = () => { +export const useRequestGroupMetaPatcher = (requestWorkspaceId = '') => { const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; @@ -96,7 +99,8 @@ export const useRequestGroupMetaPatcher = () => { fetcher.submit({ organizationId, projectId, - workspaceId, + // If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params + workspaceId: workspaceId || requestWorkspaceId, requestGroupId, patch, });