mirror of
https://github.com/Kong/insomnia.git
synced 2026-08-04 11:52:33 -04:00
feat: Pin/unpin request and collection request sorting (#9903)
add basic sort support support toggle header & sidebar add sort support fix issues from comment
This commit is contained in:
@@ -326,6 +326,10 @@ export const createNedbDatabase = <O = initOptions>(
|
||||
...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 = <O = initOptions>(
|
||||
...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'),
|
||||
|
||||
@@ -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<string, Record<string, (...args: never[]) => Promise<unknown>>>;
|
||||
|
||||
@@ -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<SocketIORequestMeta> = {}) {
|
||||
if (!patch.parentId) {
|
||||
throw new Error('New SocketIORequestMeta missing `parentId`');
|
||||
}
|
||||
|
||||
expectParentToBeSocketIORequest(patch.parentId);
|
||||
return db.docCreate<SocketIORequestMeta>(type, patch);
|
||||
}
|
||||
|
||||
export function update(requestMeta: SocketIORequestMeta, patch: Partial<SocketIORequestMeta>) {
|
||||
expectParentToBeSocketIORequest(patch.parentId || requestMeta.parentId);
|
||||
return db.docUpdate(requestMeta, patch);
|
||||
}
|
||||
|
||||
export function getByParentId(parentId: string) {
|
||||
expectParentToBeSocketIORequest(parentId);
|
||||
return db.findOne<SocketIORequestMeta>(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<SocketIORequestMeta>) {
|
||||
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<SocketIORequestMeta>(type);
|
||||
}
|
||||
@@ -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<WebSocketRequestMeta> = {}) {
|
||||
if (!patch.parentId) {
|
||||
throw new Error('New WebSocketRequestMeta missing `parentId`');
|
||||
}
|
||||
|
||||
expectParentToBeWebSocketRequest(patch.parentId);
|
||||
return db.docCreate<WebSocketRequestMeta>(type, patch);
|
||||
}
|
||||
|
||||
export function update(requestMeta: WebSocketRequestMeta, patch: Partial<WebSocketRequestMeta>) {
|
||||
expectParentToBeWebSocketRequest(patch.parentId || requestMeta.parentId);
|
||||
return db.docUpdate(requestMeta, patch);
|
||||
}
|
||||
|
||||
export function getByParentId(parentId: string) {
|
||||
expectParentToBeWebSocketRequest(parentId);
|
||||
return db.findOne<WebSocketRequestMeta>(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<WebSocketRequestMeta>) {
|
||||
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<WebSocketRequestMeta>(type);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<BaseModel, 'type'>): model is SocketIORequestMeta =>
|
||||
model.type === type;
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
pinned: false,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<BaseModel, 'type'>): model is WebSocketRequestMeta =>
|
||||
model.type === type;
|
||||
|
||||
export function init() {
|
||||
return {
|
||||
pinned: false,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<LearningFeature>(learningFeaturePromise);
|
||||
const sidebarPanelRef = useRef<ImperativePanelHandle>(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"
|
||||
>
|
||||
<Panel
|
||||
ref={sidebarPanelRef}
|
||||
id="insomnia-global-navigation-sidebar"
|
||||
className="sidebar theme--sidebar"
|
||||
defaultSize={DEFAULT_SIDEBAR_SIZE}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { href } from 'react-router';
|
||||
|
||||
import type { GrpcRequestMeta, RequestMeta } from '~/insomnia-data';
|
||||
import type { GrpcRequestMeta, RequestMeta, SocketIORequestMeta, WebSocketRequestMeta } from '~/insomnia-data';
|
||||
import { services } from '~/insomnia-data';
|
||||
import * as models from '~/models';
|
||||
import { invariant } from '~/utils/invariant';
|
||||
@@ -11,11 +11,21 @@ import type { Route } from './+types/organization.$organizationId.project.$proje
|
||||
export async function clientAction({ params, request }: Route.ClientActionArgs) {
|
||||
const { requestId } = params;
|
||||
invariant(typeof requestId === 'string', 'Request ID is required');
|
||||
const patch = (await request.json()) as Partial<RequestMeta | GrpcRequestMeta>;
|
||||
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<RequestMeta | GrpcRequestMeta>;
|
||||
patch: Partial<RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta>;
|
||||
}) => {
|
||||
const url = href(
|
||||
'/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId/update-meta',
|
||||
|
||||
@@ -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<Child> => {
|
||||
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) ||
|
||||
|
||||
@@ -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 (
|
||||
<InsomniaEventStreamProvider>
|
||||
<InsomniaTabProvider>
|
||||
@@ -221,58 +221,31 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
|
||||
<div
|
||||
className={`grid-template-app-layout relative grid h-full w-full divide-x divide-solid divide-(--hl-md) bg-(--color-bg)`}
|
||||
>
|
||||
{!isMinimal && (
|
||||
<header className="grid grid-cols-3 items-center border-b border-solid border-(--hl-md) [grid-area:Header]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-12.5 shrink-0 justify-center py-2">
|
||||
<InsomniaLogo />
|
||||
</div>
|
||||
{!isScratchPad && (
|
||||
<OrganizationSelect
|
||||
organizationId={organizationId}
|
||||
organizations={organizations || []}
|
||||
onSelect={id => {
|
||||
window.main.trackSegmentEvent({ event: SegmentEvent.organizationSwitched });
|
||||
navigate(`/organization/${id}`);
|
||||
}}
|
||||
currentPlan={currentPlan}
|
||||
isScratchpadWorkspace={!!isScratchpadWorkspace}
|
||||
/>
|
||||
)}
|
||||
<header
|
||||
className={`grid grid-cols-3 items-center border-b border-solid border-(--hl-md) [grid-area:Header] ${isMinimal ? 'hidden' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-12.5 shrink-0 justify-center py-2">
|
||||
<InsomniaLogo />
|
||||
</div>
|
||||
{!isScratchPad && (
|
||||
<OrganizationSelect
|
||||
organizationId={organizationId}
|
||||
organizations={organizations || []}
|
||||
onSelect={id => {
|
||||
window.main.trackSegmentEvent({ event: SegmentEvent.organizationSwitched });
|
||||
navigate(`/organization/${id}`);
|
||||
}}
|
||||
currentPlan={currentPlan}
|
||||
isScratchpadWorkspace={!!isScratchpadWorkspace}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!user ? <GitHubStarsButton /> : null}
|
||||
</div>
|
||||
<CommandPalette />
|
||||
<div className="flex min-w-min items-center justify-end gap-(--padding-sm) space-x-3 p-2">
|
||||
{user ? (
|
||||
<Fragment>
|
||||
<PresentUsers />
|
||||
<HeaderInviteButton
|
||||
organizationId={organizationId}
|
||||
className="border border-solid border-(--hl-md) bg-(--color-surprise) font-semibold text-(--color-font-surprise)"
|
||||
/>
|
||||
<HeaderPlanIndicator isMinimal={isMinimal} />
|
||||
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<NavLink
|
||||
to={href('/auth/login')}
|
||||
className="flex items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-4 py-1 text-sm font-semibold 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)"
|
||||
>
|
||||
Login
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className="flex items-center justify-center gap-2 rounded-xs bg-(--color-surprise) px-4 py-1 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all focus:bg-[rgba(var(--color-surprise-rgb),0.9)] focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-[rgba(var(--color-surprise-rgb),0.8)]"
|
||||
to={href('/auth/login')}
|
||||
>
|
||||
Sign up for free
|
||||
</NavLink>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{!user ? <GitHubStarsButton /> : null}
|
||||
</div>
|
||||
<CommandPalette />
|
||||
<div />
|
||||
</header>
|
||||
<div className="overflow-hidden border-b border-(--hl-md) [grid-area:Content]">
|
||||
<RunnerProvider>
|
||||
<Outlet />
|
||||
@@ -281,6 +254,90 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
|
||||
<div className="relative flex items-center overflow-hidden [grid-area:Statusbar]" data-testid="statusbar">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="flex h-full shrink grow basis-1/3 items-center">
|
||||
<TooltipTrigger>
|
||||
<ToggleButton
|
||||
className="ml-3 grow-0 gap-2 px-2 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-inset"
|
||||
onChange={value => {
|
||||
setIsSidebarCollapsed(!value);
|
||||
uiEventBus.emit(TOGGLE_PROJECT_SIDEBAR, !value);
|
||||
}}
|
||||
isSelected={!isSidebarCollapsed}
|
||||
>
|
||||
{({ isSelected }) => {
|
||||
return (
|
||||
<svg
|
||||
width={10}
|
||||
height={10}
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
{isSelected ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm12 13H7V2h7v12z"
|
||||
/>
|
||||
) : (
|
||||
<path d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm0 13V2h4v12H2zm5 0V2h7v12H7z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}}
|
||||
</ToggleButton>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
offset={8}
|
||||
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
Toggle sidebar
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<ToggleButton
|
||||
className="flex grow-0 items-center justify-center px-2 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs)"
|
||||
onChange={flag => {
|
||||
setIsMinimal(!flag);
|
||||
window.main.trackSegmentEvent({
|
||||
event: SegmentEvent.statusbarTopbarToggled,
|
||||
properties: {
|
||||
status: !flag ? 'minimal' : 'expanded',
|
||||
},
|
||||
});
|
||||
}}
|
||||
isSelected={!isMinimal}
|
||||
>
|
||||
{({ isSelected }) => {
|
||||
return (
|
||||
<svg
|
||||
width={10}
|
||||
height={10}
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className="rotate-90"
|
||||
>
|
||||
{isSelected ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm12 13H7V2h7v12z"
|
||||
/>
|
||||
) : (
|
||||
<path d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm0 13V2h4v12H2zm5 0V2h7v12H7z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}}
|
||||
</ToggleButton>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
offset={8}
|
||||
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
Toggle header
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
data-testid="settings-button"
|
||||
@@ -368,36 +425,43 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{isMinimal && (
|
||||
<div className="flex items-center justify-end gap-(--padding-sm) p-2">
|
||||
{user ? (
|
||||
<Fragment>
|
||||
<PresentUsers />
|
||||
<HeaderInviteButton className="text-(--color-font)" organizationId={organizationId} />
|
||||
<HeaderPlanIndicator isMinimal={isMinimal} />
|
||||
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<NavLink
|
||||
to={href('/auth/login')}
|
||||
className="flex items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-4 py-1 text-sm font-semibold 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)"
|
||||
>
|
||||
Login
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className="flex items-center justify-center gap-2 rounded-xs bg-(--color-surprise) px-4 py-1 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all focus:bg-[rgba(var(--color-surprise-rgb),0.9)] focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-[rgba(var(--color-surprise-rgb),0.8)]"
|
||||
to={href('/auth/login')}
|
||||
>
|
||||
Sign up for free
|
||||
</NavLink>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center justify-end gap-(--padding-sm) self-center justify-self-end border-l-0 p-2 ${isMinimal ? '[grid-area:Statusbar]' : '[grid-area:Header]'}`}
|
||||
>
|
||||
{user ? (
|
||||
<Fragment>
|
||||
<PresentUsers />
|
||||
<HeaderInviteButton
|
||||
organizationId={organizationId}
|
||||
className={
|
||||
isMinimal
|
||||
? 'text-(--color-font)'
|
||||
: 'border border-solid border-(--hl-md) bg-(--color-surprise) font-semibold text-(--color-font-surprise)'
|
||||
}
|
||||
/>
|
||||
<HeaderPlanIndicator isMinimal={isMinimal} />
|
||||
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<NavLink
|
||||
to={href('/auth/login')}
|
||||
className="flex items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-4 py-1 text-sm font-semibold 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)"
|
||||
>
|
||||
Login
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className="flex items-center justify-center gap-2 rounded-xs bg-(--color-surprise) px-4 py-1 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all focus:bg-[rgba(var(--color-surprise-rgb),0.9)] focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-[rgba(var(--color-surprise-rgb),0.8)]"
|
||||
to={href('/auth/login')}
|
||||
>
|
||||
Sign up for free
|
||||
</NavLink>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InsomniaTabProvider>
|
||||
|
||||
@@ -176,7 +176,7 @@ export const ProjectDropdown: FC<Props> = ({ 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 (
|
||||
<Fragment>
|
||||
|
||||
@@ -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<RequestAction[]>([]);
|
||||
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 = ({
|
||||
<Button
|
||||
data-testid={`Dropdown-${toKebabCase(request.name)}`}
|
||||
aria-label="Request Actions"
|
||||
className="hidden aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-hover:flex group-hover:opacity-100 group-focus:flex group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:flex data-pressed:opacity-100"
|
||||
className="aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-hover:flex group-hover:opacity-100 group-focus:flex group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:flex data-pressed:opacity-100"
|
||||
>
|
||||
<Icon icon="ellipsis" />
|
||||
</Button>
|
||||
|
||||
@@ -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<ActionItem, 'icon'>[];
|
||||
}
|
||||
|
||||
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 => (
|
||||
<MenuSection className="flex flex-1 flex-col">
|
||||
@@ -316,18 +343,52 @@ export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }:
|
||||
<Icon icon={section.icon} /> <span>{section.name}</span>
|
||||
</Header>
|
||||
<Collection items={section.items}>
|
||||
{item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
className={`flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold${item.className ? ` ${item.className}` : ''}`}
|
||||
aria-label={item.name}
|
||||
>
|
||||
<Icon icon={item.icon} />
|
||||
<span>{item.name}</span>
|
||||
{item.hint && <DropdownHint keyBindings={item.hint} />}
|
||||
</MenuItem>
|
||||
)}
|
||||
{item =>
|
||||
!item.hasSubmenu ? (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
className={`flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold${item.className ? ` ${item.className}` : ''}`}
|
||||
aria-label={item.name}
|
||||
>
|
||||
<Icon icon={item.icon} className="h-4 w-3" />
|
||||
<span>{item.name}</span>
|
||||
{item.hint && <DropdownHint keyBindings={item.hint} />}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<SubmenuTrigger>
|
||||
<MenuItem
|
||||
className={`flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold${item.className ? ` ${item.className}` : ''}`}
|
||||
aria-label={item.name}
|
||||
>
|
||||
<Icon icon={item.icon} className="h-4 w-3" />
|
||||
<span>{item.name}</span>
|
||||
<Icon icon="chevron-right" className="ml-auto" />
|
||||
</MenuItem>
|
||||
<Popover className="flex min-w-max flex-col overflow-y-hidden">
|
||||
<Menu
|
||||
aria-label={`${item.name} submenu`}
|
||||
onAction={key => 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 => (
|
||||
<MenuItem
|
||||
id={subItem.id}
|
||||
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold"
|
||||
aria-label={subItem.name}
|
||||
>
|
||||
<span>{subItem.name}</span>
|
||||
{sortOrder === subItem.id && (
|
||||
<Icon icon="check" className="h-4 w-3 justify-self-end text-(--color-success)" />
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
</Collection>
|
||||
</MenuSection>
|
||||
)}
|
||||
|
||||
27
packages/insomnia/src/ui/components/kong-logo.tsx
Normal file
27
packages/insomnia/src/ui/components/kong-logo.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
export const KongLogo = ({ ...props }: {} & React.SVGProps<SVGSVGElement>) => (
|
||||
<svg width="13" height="12" viewBox="0 0 13 12" xmlns="http://www.w3.org/2000/svg" className="z-1" {...props}>
|
||||
<g opacity="0.6" clipPath="url(#clip0_4460_9459)">
|
||||
<path
|
||||
d="M4.25676 9.83691L3.94238 10.2486L4.66461 11.4048L4.58813 11.9391H7.64696L7.85938 11.4048L6.62735 9.83691H4.26526H4.25676Z"
|
||||
fill="#CCFF00"
|
||||
/>
|
||||
<path
|
||||
d="M5.93075 2.80273L4.82617 4.79106L10.2131 11.3429L10.0602 11.9473H12.5327L12.983 9.81879L7.21375 2.80273H5.93075Z"
|
||||
fill="#CCFF00"
|
||||
/>
|
||||
<path
|
||||
d="M6.66132 1.30511L6.13452 2.29489H7.43452L9.66916 5.02774L11.0031 3.90657V3.19708L10.5443 2.53139L10.8842 2.1635L8.21622 0L6.66132 1.30511Z"
|
||||
fill="#CCFF00"
|
||||
/>
|
||||
<path
|
||||
d="M2.65098 6.84105H1.92026L0 9.34616V11.9389H2.05621L2.42157 11.4571L4.01046 9.34616H6.30457L7.0098 8.23375L4.52026 5.19434L2.64248 6.84981L2.65098 6.84105Z"
|
||||
fill="#CCFF00"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4460_9459">
|
||||
<rect width="13" height="12" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className={ROW_CLASS} style={{ paddingLeft }}>
|
||||
<span className={`${GUIDE_LINE_CSS} left-6 group-hover/tree:bg-(--hl-sm)`} />
|
||||
{kind !== 'emptyProject' && <span className={`${GUIDE_LINE_CSS} left-10 group-hover/tree:bg-(--hl-sm)`} />}
|
||||
{kind === 'emptyFolder' &&
|
||||
Array.from({ length: level + 2 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm)`}
|
||||
style={{ left: `${i + 1.5}em` }}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-3 min-w-0 flex-1 truncate text-xs">{getLabel()}</span>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
aria-label="Create in project"
|
||||
className="flex items-center justify-center gap-2 rounded-xs bg-(--hl-xxs) 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-circle" /> <span className="hidden md:block">Create</span>
|
||||
</Button>
|
||||
<Popover className="flex min-w-max flex-col overflow-y-hidden">
|
||||
<Menu
|
||||
aria-label="Create in project actions"
|
||||
selectionMode="single"
|
||||
onAction={key => {
|
||||
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 => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold"
|
||||
aria-label={item.name}
|
||||
>
|
||||
<Icon icon={item.icon} />
|
||||
<span>{item.name}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
{newWorkspaceModalState?.isOpen && (
|
||||
<NewWorkspaceModal
|
||||
isOpen
|
||||
project={project}
|
||||
storageRules={storageRules}
|
||||
scope={newWorkspaceModalState.scope}
|
||||
onOpenChange={isOpen => {
|
||||
setNewWorkspaceModalState({
|
||||
scope: newWorkspaceModalState.scope,
|
||||
isOpen,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string, string>();
|
||||
const requestToWorkspaceId = new Map<string, string>();
|
||||
const grpcRequestToWorkspaceId = new Map<string, string>();
|
||||
const wsRequestToWorkspaceId = new Map<string, string>();
|
||||
const socketIORequestToWorkspaceId = new Map<string, string>();
|
||||
// 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<RequestMeta>(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }),
|
||||
database.find<GrpcRequestMeta>(models.grpcRequestMeta.type, {
|
||||
parentId: { $in: grpcReqs.map(r => r._id) },
|
||||
@@ -113,17 +119,29 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) {
|
||||
database.find<RequestGroupMeta>(models.requestGroupMeta.type, {
|
||||
parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) },
|
||||
}),
|
||||
database.find<WebSocketRequestMeta>(models.webSocketRequestMeta.type, {
|
||||
parentId: { $in: wsReqs.map(r => r._id) },
|
||||
}),
|
||||
database.find<SocketIORequestMeta>(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;
|
||||
|
||||
@@ -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<Record<string, SortOrder>>({});
|
||||
const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState<Map<string, InsomniaFile[]>>(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 (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 border-b border-solid border-b-(--hl-md)">
|
||||
{['projects', 'konnect'].map(tabName => (
|
||||
<button
|
||||
key={tabName}
|
||||
className={`border-b-2 border-solid px-4 py-2 text-xs uppercase ${activeTab === tabName ? 'border-(--color-surprise) text-(--color-font)' : 'border-b-transparent text-(--hl) hover:bg-(--hl-xs)'}`}
|
||||
onClick={() => setActiveTab(tabName as 'projects' | 'konnect')}
|
||||
>
|
||||
{tabName === 'projects' ? `Projects (${nonKonnectProjects.length})` : `Konnect (${konnectProjects.length})`}
|
||||
</button>
|
||||
))}
|
||||
{!isScratchPad &&
|
||||
['projects', 'konnect'].map(tabName => (
|
||||
<button
|
||||
key={tabName}
|
||||
className={`border-b-2 border-solid px-4 py-2 text-xs ${activeTab === tabName ? 'border-(--color-surprise) text-(--color-font)' : 'border-b-transparent text-(--hl) hover:bg-(--hl-xs)'}`}
|
||||
onClick={() => setActiveTab(tabName as 'projects' | 'konnect')}
|
||||
>
|
||||
{tabName === 'projects' ? (
|
||||
`Projects (${nonKonnectProjects.length})`
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<KongLogo />
|
||||
Konnect ({konnectProjects.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 p-(--padding-sm)">
|
||||
<SearchField
|
||||
@@ -679,8 +783,9 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
key={virtualItem.key}
|
||||
id={item.doc._id}
|
||||
// Prefix pinned-request to the key and id to ensure pinned items have a different key and id from non-pinned items with the same doc._id
|
||||
key={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${virtualItem.key}`}
|
||||
id={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${item.doc._id}`}
|
||||
textValue={item.doc.name || item.kind}
|
||||
onAuxClick={e => {
|
||||
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
|
||||
<ProjectNode item={item} onToggle={toggleProjectOrWorkspace} storageRules={storageRules} />
|
||||
)}
|
||||
|
||||
{item.kind === 'workspace' && <WorkspaceNode item={item} onToggle={toggleProjectOrWorkspace} />}
|
||||
{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;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.kind === 'pinnedHeader' && <PinnedHeaderNode />}
|
||||
|
||||
{item.kind === 'collectionChild' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
|
||||
|
||||
{item.kind === 'pinnedRequest' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
|
||||
|
||||
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
|
||||
|
||||
{item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? (
|
||||
<EmptyNode item={item} storageRules={storageRules} />
|
||||
) : null}
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ProjectNode = ({ item, storageRules, onToggle }: ProjectNodeProps)
|
||||
: 'laptop'
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{projectName}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-base text-[rgb(var(--color-font-rgb),0.8)]">{projectName}</span>
|
||||
</div>
|
||||
{presence.length > 0 && <AvatarGroup size="small" maxAvatars={3} items={presence} />}
|
||||
{projectId !== models.project.SCRATCHPAD_PROJECT_ID && (
|
||||
|
||||
@@ -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 (
|
||||
<div className={ROW_CLASS} style={{ paddingLeft: `${level + 3}rem` }}>
|
||||
{Array.from({ length: level + 2 }, (_, i) => {
|
||||
const isActive = i === level + 1;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm) ${isActive ? 'group-hover:bg-(--hl-sm)' : ''}`}
|
||||
style={{ left: `${i + 1.5}em` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<span className={ACTIVE_BORDER_CLASS} />
|
||||
const content = (
|
||||
<>
|
||||
<Button
|
||||
aria-label={`${collapsed ? 'Expand' : 'Collapse'} ${doc.name}`}
|
||||
onPress={() => isFolder && onToggleFolder([doc._id], workspace)}
|
||||
@@ -115,28 +115,40 @@ export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
|
||||
>
|
||||
{isFolder ? <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} /> : null}
|
||||
</Button>
|
||||
|
||||
{isFolder ? (
|
||||
<Icon icon="folder" className={ICON_CLASS} />
|
||||
) : (
|
||||
<>
|
||||
<MethodBadge doc={doc} />
|
||||
</>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left transition-colors">
|
||||
{isFolder ? <Icon icon="folder" className={ICON_CLASS} /> : <MethodBadge doc={doc} />}
|
||||
<EditableInput
|
||||
value={getRequestNameOrFallback(doc)}
|
||||
name="request name"
|
||||
ariaLabel="request name"
|
||||
className="flex-1 text-base hover:bg-transparent!"
|
||||
onEditableChange={editable => setIsEditable(editable)}
|
||||
onSubmit={newName => {
|
||||
if (models.requestGroup.isRequestGroup(doc)) {
|
||||
patchGroup(doc._id, { name: newName });
|
||||
} else {
|
||||
patchRequest(doc._id, { name: newName });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!models.requestGroup.isRequestGroup(doc) && pinned && !isPinnedRequest && (
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
data-testid={`pin-${toKebabCase(doc.name)}`}
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-base text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:ring-(--hl-md) focus:outline-hidden focus:ring-inset aria-pressed:bg-(--hl-sm)"
|
||||
onPress={() => patchRequestMeta(item.doc._id, { pinned: false })}
|
||||
>
|
||||
<Icon icon="thumb-tack" />
|
||||
</Button>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-base text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
Unpin Request
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
)}
|
||||
<EditableInput
|
||||
value={getRequestNameOrFallback(doc)}
|
||||
name="request name"
|
||||
ariaLabel="request name"
|
||||
className="flex-1 px-1 text-sm"
|
||||
onEditableChange={editable => 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 && (
|
||||
<RequestGroupActionsDropdown
|
||||
requestGroup={doc}
|
||||
@@ -176,6 +188,54 @@ export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
|
||||
onOpenChange={setIsContextMenuOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${ROW_CLASS} ${className ?? ''} ${isPinnedRequest ? 'h-full! group-hover:bg-transparent! group-focus:bg-transparent!' : ''}`}
|
||||
style={{ paddingLeft: `${level + 3}rem` }}
|
||||
>
|
||||
{isPinnedRequest ? (
|
||||
<>
|
||||
<span className={`${GUIDE_LINE_CSS} left-6 group-hover/tree:bg-(--hl-sm)`} />
|
||||
<span className={`${GUIDE_LINE_CSS} left-10 group-hover/tree:bg-(--hl-sm)`} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: level + 2 }, (_, i) => {
|
||||
const isActive = i === level + 1;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm) ${isActive ? 'group-hover:bg-(--hl-sm)' : ''}`}
|
||||
style={{ left: `${i + 1.5}em` }}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<span className={ACTIVE_BORDER_CLASS} />
|
||||
{isPinnedRequest ? (
|
||||
<div
|
||||
className={`ml-2 flex h-full min-w-0 flex-1 items-center overflow-hidden border-x border-solid border-(--hl-md) bg-(--hl-xs) pr-2 group-hover:bg-(--hl-sm) group-focus:bg-(--hl-sm) ${isLastPinned ? 'border-b' : ''}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PinnedHeaderNode = () => {
|
||||
return (
|
||||
<div className={`${ROW_CLASS} group h-full! pl-12 group-hover:bg-transparent!`}>
|
||||
<span className={`${GUIDE_LINE_CSS} left-6 group-hover/tree:bg-(--hl-sm)`} />
|
||||
<span className={`${GUIDE_LINE_CSS} left-10 group-hover/tree:bg-(--hl-sm)`} />
|
||||
<div className="ml-2 flex h-full w-full items-center border border-b-0 border-solid border-(--hl-md) bg-(--hl-xs) p-1 text-(--hl)">
|
||||
<Icon icon="thumb-tack" className="h-4 w-4 shrink-0" />
|
||||
<span className="ml-1 text-xs">Pinned</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Workspace> {
|
||||
|
||||
//unsynced workspace in clod sync project
|
||||
type UnsyncedWorkspaceDoc = InsomniaFile & { _id: string };
|
||||
export type UnsyncedWorkspaceFlatItem = Exclude<BaseFlatItem<any>, 'doc'> &
|
||||
export type UnsyncedWorkspaceFlatItem = Omit<BaseFlatItem<any>, 'doc'> &
|
||||
Pick<WorkspaceFlatItem, 'project'> & {
|
||||
kind: 'unsyncedWorkspace';
|
||||
doc: UnsyncedWorkspaceDoc;
|
||||
@@ -60,4 +60,34 @@ export interface CollectionChildFlatItem extends BaseFlatItem<Child['doc']> {
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export type FlatItem = ProjectFlatItem | WorkspaceFlatItem | CollectionChildFlatItem | UnsyncedWorkspaceFlatItem;
|
||||
export interface PinnedRequestFlatItem extends Omit<CollectionChildFlatItem, 'kind'> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
<span className={ACTIVE_BORDER_CLASS} />
|
||||
<span className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm)`} style={{ left: '1.5em' }} />
|
||||
<Button className={TOGGLE_BTN_CLASS} aria-label="" isDisabled />
|
||||
<Button
|
||||
onPress={() => {
|
||||
const { project, doc, organizationId } = item;
|
||||
const { remoteId: backendProjectId } = doc;
|
||||
if (project.remoteId && backendProjectId) {
|
||||
pullRemoteFileFetcher.submit({
|
||||
backendProjectId,
|
||||
remoteId: project.remoteId,
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
isDisabled={isPulling}
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left opacity-60 transition-colors ${isPulling ? 'animate-pulse cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Icon icon={isPulling ? 'spinner' : 'cloud-download'} className={ICON_CLASS} spin={isPulling} />
|
||||
<span>{item.doc.name}</span>
|
||||
</Button>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
onPress={() => {
|
||||
const { project, doc, organizationId } = item;
|
||||
const { remoteId: backendProjectId } = doc;
|
||||
if (project.remoteId && backendProjectId) {
|
||||
pullRemoteFileFetcher.submit({
|
||||
backendProjectId,
|
||||
remoteId: project.remoteId,
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
isDisabled={isPulling}
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left opacity-60 transition-colors ${isPulling ? 'animate-pulse cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-sm px-2">
|
||||
<Icon icon={isPulling ? 'spinner' : 'cloud-download'} className={ICON_CLASS} spin={isPulling} />
|
||||
</div>
|
||||
<span className="text-base text-[rgb(var(--color-font-rgb),0.8)]">{item.doc.name}</span>
|
||||
</Button>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
Click to fetch this file
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 ? <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} /> : null}
|
||||
</Button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left transition-colors">
|
||||
<Icon icon={scopeToIconMap[workspaceScope]} className={ICON_CLASS} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{workspaceName}</span>
|
||||
<div
|
||||
className={`${scopeToBgColorMap[workspaceScope]} ${scopeToTextColorMap[workspaceScope]} flex h-5 w-5 items-center justify-center rounded-sm px-2`}
|
||||
>
|
||||
<Icon icon={scopeToIconMap[workspaceScope]} className={ICON_CLASS} />
|
||||
</div>
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-base">{workspaceName}</span>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<SidebarWorkspaceDropdown workspace={doc} project={project} organizationId={organizationId} />
|
||||
<SidebarWorkspaceDropdown
|
||||
workspace={doc}
|
||||
project={project}
|
||||
sortOrder={sortOrder}
|
||||
organizationId={organizationId}
|
||||
onSortOrderChange={onSortOrderChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<UIEventType, EventHandler[]> = {
|
||||
CLOSE_TAB: [],
|
||||
CHANGE_ACTIVE_ENV: [],
|
||||
[TOGGLE_PROJECT_SIDEBAR]: [],
|
||||
[CLOUD_SYNC_FILE_CHANGE]: [],
|
||||
[OAUTH2_AUTHORIZATION_STATUS_CHANGE]: [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user