diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 91b10e80ef..9ff15fe1cb 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -182,7 +182,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { {activeProject && models.project.isRemoteProject(activeProject) && } - + diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx new file mode 100644 index 0000000000..034252955f --- /dev/null +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx @@ -0,0 +1,462 @@ +import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core'; +import { + exportGlobalEnvironmentToFile, + exportMcpClientToFile, + exportMockServerToFile, +} from 'insomnia/src/ui/components/settings/import-export'; +import React, { Fragment, useState } from 'react'; +import { + Button, + Collection, + Dialog, + Header, + Heading, + Label, + Menu, + MenuItem, + MenuSection, + MenuTrigger, + Modal, + ModalOverlay, + Popover, + Radio, + RadioGroup, +} from 'react-aria-components'; +import { href } from 'react-router'; + +import type { Project, Workspace } from '~/insomnia-data'; +import { models } 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 { useWorkspaceDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.delete'; +import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.update'; +import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; +import type { CreateRequestType } from '~/ui/hooks/use-request'; + +import { getProductName } from '../../../common/constants'; +import { getWorkspaceLabel } from '../../../common/get-workspace-label'; +import type { PlatformKeyCombinations } from '../../../common/settings'; +import { SegmentEvent } from '../../analytics'; +import { DropdownHint } from '../base/dropdown/dropdown-hint'; +import { Icon } from '../icon'; +import { showModal } from '../modals'; +import { ExportRequestsModal } from '../modals/export-requests-modal'; +import { ImportModal } from '../modals/import-modal/import-modal'; +import { PasteCurlModal } from '../modals/paste-curl-modal'; +import { PromptModal } from '../modals/prompt-modal'; +import { WorkspaceDuplicateModal } from '../modals/workspace-duplicate-modal'; +import { WorkspaceSettingsModal } from '../modals/workspace-settings-modal'; + +interface Props { + workspace: Workspace; + project: Project; + organizationId: string; +} + +interface ActionItem { + id: string; + name: string; + icon: IconName; + hint?: PlatformKeyCombinations; + action: () => void; + className?: string; +} + +interface ActionSection { + name: string; + id: string; + icon: IconProp; + items: ActionItem[]; +} + +export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: Props) => { + const projectId = project._id; + const workspaceId = workspace._id; + + const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isPasteCurlModalOpen, setPasteCurlModalOpen] = useState(false); + + const updateWorkspaceFetcher = useWorkspaceUpdateActionFetcher(); + const deleteWorkspaceFetcher = useWorkspaceDeleteActionFetcher(); + const newRequestFetcher = useRequestNewActionFetcher(); + const newRequestGroupFetcher = useRequestGroupNewActionFetcher(); + + const tabNavigate = useTabNavigate(); + + const workspaceName = workspace.name; + const projectName = project.name || getProductName(); + const isCollection = workspace.scope === 'collection'; + + const createRequest = (requestType: CreateRequestType) => { + newRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + requestType, + parentId: workspaceId, + }); + }; + + const openInNewTab = (isRunner = false) => { + tabNavigate( + { organization: organizationId, project, workspace, item: workspace }, + isRunner ? { shouldNavigate: true, asRunner: true } : { withTab: true, shouldNavigate: true }, + ); + }; + + const createSections: ActionSection[] = isCollection + ? [ + { + name: 'Create', + id: 'create', + icon: 'plus', + items: [ + { + id: 'New Folder', + name: 'New Folder', + icon: 'folder', + action: () => + showModal(PromptModal, { + title: 'New Folder', + defaultValue: 'My Folder', + submitName: 'Create', + label: 'Name', + selectText: true, + onComplete: (name: string) => + newRequestGroupFetcher.submit({ + organizationId, + projectId, + workspaceId, + parentId: workspaceId, + name, + }), + }), + }, + { + id: 'HTTP', + name: 'HTTP Request', + icon: 'plus-circle', + action: () => createRequest('HTTP'), + }, + { + id: 'Event Stream', + name: 'Event Stream Request (SSE)', + icon: 'plus-circle', + action: () => createRequest('Event Stream'), + }, + { + id: 'GraphQL Request', + name: 'GraphQL Request', + icon: 'plus-circle', + action: () => createRequest('GraphQL'), + }, + { + id: 'gRPC Request', + name: 'gRPC Request', + icon: 'plus-circle', + action: () => createRequest('gRPC'), + }, + { + id: 'WebSocket Request', + name: 'WebSocket Request', + icon: 'plus-circle', + action: () => createRequest('WebSocket'), + }, + { + id: 'Socket.IO Request', + name: 'Socket.IO Request', + icon: 'plus-circle', + action: () => createRequest('SocketIO'), + }, + ], + }, + { + name: 'Import', + id: 'import-create', + icon: 'file-import', + items: [ + { + id: 'From Curl', + name: 'From Curl', + icon: 'terminal', + action: () => setPasteCurlModalOpen(true), + }, + { + id: 'from-file', + name: 'From File', + icon: 'file-import', + action: () => setIsImportModalOpen(true), + }, + ], + }, + { + name: 'Run', + id: 'run', + icon: 'circle-play', + items: [ + { + id: 'RunCollection', + name: 'Run Collection', + icon: 'circle-play', + action: () => openInNewTab(true), + }, + ], + }, + ] + : []; + + const actionSection: ActionSection = { + name: 'Actions', + id: 'actions', + icon: 'cog', + items: [ + { + id: 'OpenInNewTab', + name: 'Open in New Tab', + icon: 'external-link-alt', + action: openInNewTab, + }, + ...(!models.workspace.isMcp(workspace) + ? [ + { + id: 'Duplicate', + name: 'Duplicate / Move', + icon: 'copy' as IconName, + action: () => setIsDuplicateModalOpen(true), + }, + ] + : []), + { + id: 'Rename', + name: 'Rename', + icon: 'pen-to-square' as IconName, + action: () => + showModal(PromptModal, { + title: `Rename ${getWorkspaceLabel(workspace).singular}`, + defaultValue: workspaceName, + submitName: 'Rename', + selectText: true, + label: 'Name', + onComplete: (name: string) => + updateWorkspaceFetcher.submit({ + organizationId, + projectId, + patch: { name, workspaceId }, + }), + }), + }, + { + id: 'Export', + name: 'Export', + icon: 'file-export', + action: () => { + window.main.trackSegmentEvent({ + event: SegmentEvent.exportStarted, + properties: { source: `${workspace.scope}-list` }, + }); + if (workspace.scope === 'mock-server') { + return exportMockServerToFile(workspace); + } + if (workspace.scope === 'environment') { + return exportGlobalEnvironmentToFile(workspace); + } + if (workspace.scope === 'mcp') { + return exportMcpClientToFile(workspace); + } + return setIsExportModalOpen(true); + }, + }, + { + id: 'Settings', + name: 'Settings', + icon: 'gear', + action: () => setIsSettingsModalOpen(true), + }, + { + id: 'Delete', + name: 'Delete', + icon: 'trash', + className: 'text-(--color-danger)', + action: () => setIsDeleteModalOpen(true), + }, + ], + }; + + const allSections: ActionSection[] = [...createSections, actionSection]; + + return ( + + + + + + allSections + .find(s => s.items.find(a => a.id === key)) + ?.items.find(a => a.id === key) + ?.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" + > + {section => ( + +
+ {section.name} +
+ + {item => ( + + + {item.name} + {item.hint && } + + )} + +
+ )} +
+
+
+ + {isDuplicateModalOpen && ( + setIsDuplicateModalOpen(false)} /> + )} + {isImportModalOpen && ( + setIsImportModalOpen(false)} + from={{ type: 'file' }} + projectName={projectName} + workspaceName={workspaceName} + organizationId={organizationId} + defaultProjectId={projectId} + defaultWorkspaceId={workspaceId} + /> + )} + {isExportModalOpen && ( + setIsExportModalOpen(false)} /> + )} + {isSettingsModalOpen && ( + setIsSettingsModalOpen(false)} /> + )} + {isDeleteModalOpen && ( + setIsDeleteModalOpen(false)} + isDismissable + className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" + > + setIsDeleteModalOpen(false)} + className="max-h-full w-full max-w-2xl rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" + > + + {({ close }) => ( +
+
+ Delete {getWorkspaceLabel(workspace).singular} + +
+ + +
+

+ This will permanently delete the{' '} + {workspaceName}{' '} + {getWorkspaceLabel(workspace).singular} +

+ {models.project.isRemoteProject(project) && ( + + +
+ +
+ Remove Local Copy +

The project will still exist on the Cloud.

+
+
+ +
+ Delete Permanently +

+ The project will be deleted everywhere. You cannot undo this action. +

+
+
+
+
+ )} +
+ {deleteWorkspaceFetcher.data?.error && ( +

{deleteWorkspaceFetcher.data.error}

+ )} +
+ +
+
+
+ )} +
+
+
+ )} + {isPasteCurlModalOpen && ( + { + newRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + requestType: 'From Curl', + parentId: workspaceId, + req, + }); + }} + onHide={() => setPasteCurlModalOpen(false)} + /> + )} +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx index 8ecf2ffc6a..d5db82d803 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx @@ -1,6 +1,7 @@ import { Button } from 'react-aria-components'; import { scopeToIconMap } from '~/common/get-workspace-label'; +import { SidebarWorkspaceDropdown } from '~/ui/components/dropdowns/sidebar-workspace-dropdown'; import { Icon } from '../../icon'; import { @@ -18,7 +19,7 @@ interface WorkspaceNodeProps { } export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => { - const { doc, collapsed } = item; + const { doc, collapsed, project, organizationId } = item; const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc; return ( @@ -38,6 +39,9 @@ export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => { {workspaceName} +
+ +
); };