From 40d294e8aeaf9be0eecccab264b8c7707a69f786 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Fri, 28 Oct 2022 14:04:09 +0200 Subject: [PATCH] Feat/project route data loading (#5327) * remove activity router * add global error route * make vcs a singleton * move modals outside of root * add project route data loader * add project actions * add rename/delete project actions * lift data dependencies up and move modals * add remote projects action/loader * add overlay to duplicate modal and use invariant in actions * use revalidate to refetch the route data on imports * remove unused type checks * more invariants and some renaming --- packages/insomnia/src/sync/vcs/vcs.ts | 11 + .../components/dropdowns/project-dropdown.tsx | 242 ++++--- .../dropdowns/remote-workspaces-dropdown.tsx | 113 +-- .../dropdowns/workspace-card-dropdown.tsx | 125 ++-- .../modals/project-settings-modal.tsx | 63 +- .../modals/workspace-duplicate-modal.tsx | 149 ++-- .../modals/workspace-settings-modal.tsx | 10 +- .../ui/components/settings/import-export.tsx | 15 +- .../src/ui/components/workspace-card.tsx | 61 +- packages/insomnia/src/ui/hooks/use-vcs.ts | 7 +- packages/insomnia/src/ui/hooks/workspace.ts | 125 ---- packages/insomnia/src/ui/index.tsx | 285 +++++++- .../insomnia/src/ui/redux/modules/import.ts | 7 + packages/insomnia/src/ui/routes/actions.tsx | 213 ++++++ packages/insomnia/src/ui/routes/error.tsx | 64 ++ packages/insomnia/src/ui/routes/modals.tsx | 239 +++++++ packages/insomnia/src/ui/routes/project.tsx | 650 +++++++++++------- .../src/ui/routes/remote-collections.tsx | 74 ++ packages/insomnia/src/ui/routes/root.tsx | 181 +---- 19 files changed, 1699 insertions(+), 935 deletions(-) delete mode 100644 packages/insomnia/src/ui/hooks/workspace.ts create mode 100644 packages/insomnia/src/ui/routes/actions.tsx create mode 100644 packages/insomnia/src/ui/routes/error.tsx create mode 100644 packages/insomnia/src/ui/routes/modals.tsx create mode 100644 packages/insomnia/src/ui/routes/remote-collections.tsx diff --git a/packages/insomnia/src/sync/vcs/vcs.ts b/packages/insomnia/src/sync/vcs/vcs.ts index 0f10c9610a..d9ed18206b 100644 --- a/packages/insomnia/src/sync/vcs/vcs.ts +++ b/packages/insomnia/src/sync/vcs/vcs.ts @@ -10,6 +10,7 @@ import { strings } from '../../common/strings'; import { BaseModel } from '../../models'; import Store from '../store'; import type { BaseDriver } from '../store/drivers/base'; +import type FileSystemDriver from '../store/drivers/file-system-driver'; import compress from '../store/hooks/compress'; import type { BackendProject, @@ -1524,3 +1525,13 @@ function _generateSnapshotID(parentId: string, backendProjectId: string, state: return hash.digest('hex'); } + +let vcsInstance: VCS | null = null; + +export const getVCS = () => vcsInstance; + +export const initVCS = async (driver: FileSystemDriver, conflictHandler: ConflictHandler) => { + vcsInstance = new VCS(driver, conflictHandler); + + return vcsInstance; +}; diff --git a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx index d197eee697..89c6f41dae 100644 --- a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx @@ -1,24 +1,29 @@ -import { partition } from 'ramda'; -import React, { FC, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { invariant } from '@remix-run/router'; +import React, { FC, Fragment, useState } from 'react'; +import { useFetcher, useNavigate, useRevalidator } from 'react-router-dom'; import styled from 'styled-components'; import { strings } from '../../../common/strings'; -import { isDefaultProject, isRemoteProject, Project, projectHasSettings } from '../../../models/project'; -import { VCS } from '../../../sync/vcs/vcs'; -import { useRemoteProjects } from '../../hooks/project'; -import { setActiveProject } from '../../redux/modules/global'; -import { createProject } from '../../redux/modules/project'; -import { selectActiveProject, selectProjects } from '../../redux/selectors'; +import { + isDefaultProject, + isRemoteProject, + Project, +} from '../../../models/project'; import { Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; import { DropdownItem } from '../base/dropdown/dropdown-item'; -import { showModal } from '../modals'; +import { showPrompt } from '../modals'; import ProjectSettingsModal from '../modals/project-settings-modal'; import { SvgIcon, SvgIconProps } from '../svg-icon'; import { Tooltip } from '../tooltip'; -import { svgPlacementHack, tooltipIconPlacementHack } from './dropdown-placement-hacks'; +import { svgPlacementHack } from './dropdown-placement-hacks'; + +const Item = styled.div({ + display: 'flex', + alignItems: 'center', + gap: 'var(--padding-sm)', +}); const Checkmark = styled(SvgIcon)({ '&&': { @@ -38,100 +43,147 @@ const StyledSvgIcon = styled(SvgIcon)({ }, }); -const StyledTooltip = styled(Tooltip)({ - '&&': { - ...tooltipIconPlacementHack, - }, -}); - -const Item = styled.div({ - display: 'flex', - alignItems: 'center', - gap: 'var(--padding-sm)', -}); - -const TooltipIcon = ({ message, icon }: { message: string; icon: SvgIconProps['icon'] }) => ( - +const TooltipIcon = ({ + message, + icon, +}: { + message: string; + icon: SvgIconProps['icon']; +}) => ( + - + ); -const spinner = ; -const home = ; -const remoteProject = ; -const localProject = ; +const Spinner = () => ; + +const HomeIcon = () => ( + +); + +const RemoteProjectIcon = () => ( + +); + +const LocalProjectIcon = () => ( + +); interface Props { - vcs?: VCS; + activeProject: Project; + projects: Project[]; } -const ProjectDropdownItem: FC<{ - project: Project; - isActive: boolean; - setActive: (projectId: string) => void; -}> = ({ isActive, project, setActive }) => { - const { _id, name } = project; - const isDefault = isDefaultProject(project); - const isRemote = isRemoteProject(project); +export const ProjectDropdown: FC = ({ activeProject, projects }) => { + const { revalidate, state } = useRevalidator(); + const navigate = useNavigate(); + const { submit } = useFetcher(); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + const defaultProject = projects.find(isDefaultProject); + invariant(defaultProject, 'No default project found'); + const userProjects = projects.filter(p => !isDefaultProject(p)); return ( - setActive(_id)} - > - - - {isDefault ? home : isRemote ? remoteProject : localProject} - {name} - {isActive && } - - - ); -}; -ProjectDropdownItem.displayName = DropdownItem.name; // This is required because the Dropdown component will otherwise silently disregard this component. - -export const ProjectDropdown: FC = ({ vcs }) => { - const { loading, refresh } = useRemoteProjects(vcs); - - const projects = useSelector(selectProjects); - - const activeProject = useSelector(selectActiveProject); - const dispatch = useDispatch(); - const setActive = useCallback((projectId: string) => dispatch(setActiveProject(projectId)), [dispatch]); - const createNew = useCallback(() => dispatch(createProject()), [dispatch]); - const showSettings = useCallback(() => showModal(ProjectSettingsModal), []); - - const renderProject = useCallback((project: Project) => ( - - ), [setActive, activeProject._id]); - - const [defaultProjects, userProjects] = partition(isDefaultProject, projects); - - return ( - - - {activeProject.name} - - - {defaultProjects.map(renderProject)} - All {strings.project.plural.toLowerCase()}{' '}{loading && spinner} - {userProjects.map(renderProject)} - {projectHasSettings(activeProject) && <> - - - {strings.project.singular} Settings + + + + {activeProject.name} + + + navigate(`/project/${defaultProject._id}`)} + > + + + {defaultProject.name} + {defaultProject._id === activeProject._id && ( + + )} + - } + + All {strings.project.plural.toLowerCase()}{' '} + {state === 'loading' && } + + {userProjects.map(project => { + return ( + navigate(`/project/${project._id}`)} + > + + {isRemoteProject(project) ? ( + + ) : ( + + )} + {project.name} + {project._id === activeProject._id && ( + + )} + + + ); + })} + {!isDefaultProject(activeProject) && ( + <> + + setIsSettingsModalOpen(true)}> + + + {strings.project.singular} Settings + + + + )} - - - Create new {strings.project.singular.toLowerCase()} - - + + { + const defaultValue = `My ${strings.project.singular}`; + + showPrompt({ + title: `Create New ${strings.project.singular}`, + submitName: 'Create', + cancelable: true, + placeholder: defaultValue, + defaultValue, + selectText: true, + onComplete: async name => + submit( + { + name, + }, + { + action: '/project/new', + method: 'post', + } + ), + }); + }} + > + + Create new{' '} + {strings.project.singular.toLowerCase()} + + + + {isSettingsModalOpen && ( + setIsSettingsModalOpen(false)} + project={activeProject} + /> + )} + ); }; diff --git a/packages/insomnia/src/ui/components/dropdowns/remote-workspaces-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/remote-workspaces-dropdown.tsx index 100aa3ccbb..d644945459 100644 --- a/packages/insomnia/src/ui/components/dropdowns/remote-workspaces-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/remote-workspaces-dropdown.tsx @@ -1,13 +1,11 @@ -import React, { FC } from 'react'; -import { useSelector } from 'react-redux'; +import React, { FC, useEffect } from 'react'; +import { useFetcher } from 'react-router-dom'; import styled from 'styled-components'; import { isLoggedIn } from '../../../account/session'; import { strings } from '../../../common/strings'; -import { isRemoteProject } from '../../../models/project'; -import { VCS } from '../../../sync/vcs/vcs'; -import { useRemoteWorkspaces } from '../../hooks/workspace'; -import { selectActiveProject } from '../../redux/selectors'; +import { RemoteProject } from '../../../models/project'; +import { RemoteCollectionsLoaderData } from '../../routes/remote-collections'; import { Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; @@ -17,7 +15,7 @@ import { Button } from '../themed-button'; import { Tooltip } from '../tooltip'; interface Props { - vcs?: VCS | null; + project: RemoteProject; } const PullButton = styled(Button)({ @@ -26,31 +24,63 @@ const PullButton = styled(Button)({ }, }); -export const RemoteWorkspacesDropdown: FC = ({ vcs }) => { - const { - loading, - refresh, - missingBackendProjects, - pullingBackendProjects, - pull, - } = useRemoteWorkspaces(vcs || undefined); +const RemoteWorkspaceDropdownItem: FC<{ + remoteId: string; + backendProjectId: string; + name: string; + projectId: string; +}> = ({ remoteId, backendProjectId, name, projectId }) => { + const { submit, state } = useFetcher(); - const project = useSelector(selectActiveProject); - if (!project) { - return null; - } + return ( + + submit( + { + remoteId, + backendProjectId, + }, + { + action: `project/${projectId}/remote-collections/pull`, + method: 'post', + } + ) + } + > + {state === 'submitting' ? ( + + ) : ( + + )} + + Pull {name} + + + ); +}; - const isRemote = isRemoteProject(project); +RemoteWorkspaceDropdownItem.displayName = DropdownItem.name; - // Don't show the pull dropdown if we are not in a remote project - if (!isRemote) { - return null; - } +export const RemoteWorkspacesDropdown: FC = ({ project }) => { + const { load, data, state } = useFetcher(); + + useEffect(() => { + if (isLoggedIn() && project && state === 'idle' && !data) { + load(`/project/${project._id}/remote-collections`); + } + }, [data, load, project, state]); + + const remoteBackendProjects = data?.remoteBackendProjects ?? []; // Show a disabled button if remote project but not logged in if (!isLoggedIn()) { return ( - + Pull @@ -59,36 +89,29 @@ export const RemoteWorkspacesDropdown: FC = ({ vcs }) => { } return ( - + load(`/project/${project._id}/remote-collections`)}> Pull Remote {strings.collection.plural} - These {strings.collection.plural.toLowerCase()} have been shared with you via Insomnia - Sync and do not yet exist on your machine. + These {strings.collection.plural.toLowerCase()} have been shared with + you via Insomnia Sync and do not yet exist on your machine. {' '} - {loading && } + {state === 'loading' && } - {missingBackendProjects.length === 0 && ( + {remoteBackendProjects.length === 0 && ( Nothing to pull )} - {missingBackendProjects.map(p => ( - pull(p)} - > - {pullingBackendProjects[p.id] ? ( - - ) : ( - - )} - - Pull {p.name} - - + {remoteBackendProjects.map(backendProject => ( + ))} ); diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx index c9c900b680..4793823793 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -1,12 +1,11 @@ -import React, { FC, useCallback, useState } from 'react'; +import React, { FC, Fragment, useCallback, useState } from 'react'; +import { useFetcher } from 'react-router-dom'; import { parseApiSpec } from '../../../common/api-specs'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render'; -import * as models from '../../../models'; import type { ApiSpec } from '../../../models/api-spec'; import getWorkspaceName from '../../../models/helpers/get-workspace-name'; -import * as workspaceOperations from '../../../models/helpers/workspace-operations'; import { Project } from '../../../models/project'; import type { Workspace } from '../../../models/workspace'; import { WorkspaceScopeKeys } from '../../../models/workspace'; @@ -27,49 +26,11 @@ interface Props { workspace: Workspace; apiSpec: ApiSpec; project: Project; + projects: Project[]; } const spinner = ; -const useWorkspaceHandlers = ({ workspace, apiSpec }: Props) => { - const handleDuplicate = useCallback(() => { - showModal(WorkspaceDuplicateModal, { workspace, apiSpec }); - }, [workspace, apiSpec]); - - const workspaceName = getWorkspaceName(workspace, apiSpec); - - const handleRename = useCallback(() => { - showPrompt({ - title: `Rename ${getWorkspaceLabel(workspace).singular}`, - defaultValue: workspaceName, - submitName: 'Rename', - selectText: true, - label: 'Name', - onComplete: name => workspaceOperations.rename(name, workspace, apiSpec), - }); - }, [apiSpec, workspace, workspaceName]); - - const handleDelete = useCallback(() => { - const label = getWorkspaceLabel(workspace); - showModal(AskModal, { - title: `Delete ${label.singular}`, - message: `Do you really want to delete "${workspaceName}"?`, - yesText: 'Yes', - noText: 'Cancel', - onDone: async (isYes: boolean) => { - if (!isYes) { - return; - } - - await models.stats.incrementDeletedRequestsForDescendents(workspace); - await models.workspace.remove(workspace); - }, - }); - }, [workspace, workspaceName]); - - return { handleDelete, handleDuplicate, handleRename }; -}; - const useDocumentActionPlugins = ({ workspace, apiSpec, project }: Props) => { const [actionPlugins, setActionPlugins] = useState([]); const { startLoading, stopLoading, isLoading } = useLoadingRecord(); @@ -116,21 +77,83 @@ const useDocumentActionPlugins = ({ workspace, apiSpec, project }: Props) => { }; export const WorkspaceCardDropdown: FC = props => { - const { handleDelete, handleDuplicate, handleRename } = useWorkspaceHandlers(props); + const { workspace, apiSpec, projects } = props; + const fetcher = useFetcher(); + const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); + + const workspaceName = getWorkspaceName(workspace, apiSpec); + const { refresh, renderPluginDropdownItems } = useDocumentActionPlugins(props); return ( - - + + + + + - Duplicate - Rename + setIsDuplicateModalOpen(true)}>Duplicate + { + showPrompt({ + title: `Rename ${getWorkspaceLabel(workspace).singular}`, + defaultValue: workspaceName, + submitName: 'Rename', + selectText: true, + label: 'Name', + onComplete: name => + fetcher.submit( + { name, workspaceId: workspace._id }, + { + action: `/project/${workspace.parentId}/workspace/update`, + method: 'post', + } + ), + }); + }} + > + Rename + - {renderPluginDropdownItems()} + {renderPluginDropdownItems()} - + - Delete - + { + const label = getWorkspaceLabel(workspace); + showModal(AskModal, { + title: `Delete ${label.singular}`, + message: `Do you really want to delete "${workspaceName}"?`, + yesText: 'Yes', + noText: 'Cancel', + onDone: async (isYes: boolean) => { + if (!isYes) { + return; + } + + fetcher.submit( + { workspaceId: workspace._id }, + { + action: `/project/${workspace.parentId}/workspace/delete`, + method: 'post', + } + ); + }, + }); + }} + > + Delete + + + {isDuplicateModalOpen && ( + setIsDuplicateModalOpen(false)} + workspace={workspace} + projects={projects} + /> + )} + ); }; diff --git a/packages/insomnia/src/ui/components/modals/project-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/project-settings-modal.tsx index 44f86ab27b..bde8f3e26a 100644 --- a/packages/insomnia/src/ui/components/modals/project-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/project-settings-modal.tsx @@ -1,38 +1,30 @@ -import React, { forwardRef, useImperativeHandle, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { FC, useEffect, useRef } from 'react'; +import { useFetcher } from 'react-router-dom'; import { strings } from '../../../common/strings'; -import * as models from '../../../models/index'; -import { isRemoteProject, projectHasSettings } from '../../../models/project'; -import { removeProject } from '../../redux/modules/project'; -import { selectActiveProject } from '../../redux/selectors'; +import { isRemoteProject, Project } from '../../../models/project'; import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalHeader } from '../base/modal-header'; import { PromptButton } from '../base/prompt-button'; import { HelpTooltip } from '../help-tooltip'; -export interface ProjectSettingsModalHandle { - show: () => void; - hide: () => void; +export interface ProjectSettingsModalProps extends ModalProps { + project: Project; } -export const ProjectSettingsModal = forwardRef((_, ref) => { + +export const ProjectSettingsModal: FC = ({ project, onHide }) => { const modalRef = useRef(null); - const project = useSelector(selectActiveProject); - const dispatch = useDispatch(); + const { submit } = useFetcher(); - useImperativeHandle(ref, () => ({ - hide: () => modalRef.current?.hide(), - show: () => modalRef.current?.show(), - }), []); + useEffect(() => { + modalRef.current?.show(); + }); - if (!projectHasSettings(project)) { - return null; - } const isRemote = isRemoteProject(project); return ( - + {strings.project.singular} Settings{' '}
{project._id}
@@ -44,7 +36,11 @@ export const ProjectSettingsModal = forwardRef - To rename a {strings.remoteProject.singular.toLowerCase()} {strings.project.singular.toLowerCase()} please visit the insomnia website. + To rename a {strings.remoteProject.singular.toLowerCase()}{' '} + {strings.project.singular.toLowerCase()} please visit{' '} + + the insomnia website. + @@ -54,7 +50,17 @@ export const ProjectSettingsModal = forwardRef models.project.update(project, { name: event.target.value })} + onChange={e => { + submit( + { + name: e.currentTarget.value, + }, + { + action: `/project/${project._id}/rename`, + method: 'post', + } + ); + }} /> )} @@ -62,10 +68,12 @@ export const ProjectSettingsModal = forwardRefActions
{ - dispatch(removeProject(project)); - modalRef.current?.hide(); - }} + onClick={() => + submit( + {}, + { method: 'post', action: `/project/${project._id}/delete` } + ) + } className="width-auto btn btn--clicky inline-block" > Delete @@ -74,7 +82,8 @@ export const ProjectSettingsModal = forwardRef ); -}); +}; + ProjectSettingsModal.displayName = 'ProjectSettingsModal'; export default ProjectSettingsModal; diff --git a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx index baf88dce4a..54cbd4d080 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx @@ -1,107 +1,70 @@ -import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { FC, useEffect, useRef } from 'react'; +import { OverlayContainer } from 'react-aria'; +import { useFetcher } from 'react-router-dom'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import { strings } from '../../../common/strings'; -import * as models from '../../../models'; -import { ApiSpec } from '../../../models/api-spec'; -import * as workspaceOperations from '../../../models/helpers/workspace-operations'; -import { isDefaultProject, isLocalProject, isRemoteProject } from '../../../models/project'; -import { isDesign, Workspace } from '../../../models/workspace'; -import { initializeLocalBackendProjectAndMarkForSync } from '../../../sync/vcs/initialize-backend-project'; -import { useVCS } from '../../hooks/use-vcs'; -import { activateWorkspace } from '../../redux/modules/workspace'; -import { selectIsLoggedIn, selectProjects } from '../../redux/selectors'; +import { isDefaultProject, isLocalProject, Project } from '../../../models/project'; +import { Workspace } from '../../../models/workspace'; import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalFooter } from '../base/modal-footer'; import { ModalHeader } from '../base/modal-header'; -interface ErrorModalOptions { - workspace?: Workspace; - apiSpec?: ApiSpec; - onDone?: () => void; +interface WorkspaceDuplicateModalProps extends ModalProps { + workspace: Workspace; + projects: Project[]; } -export interface WorkspaceDuplicateModalHandle { - show: (options: ErrorModalOptions) => void; - hide: () => void; -} -export const WorkspaceDuplicateModal = forwardRef((_, ref) => { + +export const WorkspaceDuplicateModal: FC = ({ workspace, projects, ...modalProps }) => { + const { Form } = useFetcher(); const modalRef = useRef(null); - const [state, setState] = useState({}); - useImperativeHandle(ref, () => ({ - hide: () => { - modalRef.current?.hide(); - }, - show: ({ workspace, apiSpec, onDone }: ErrorModalOptions) => { - setState({ workspace, apiSpec, onDone }); - modalRef.current?.show(); - }, - }), []); + useEffect(() => { + modalRef.current?.show(); + }, []); - const projects = useSelector(selectProjects); - const isLoggedIn = useSelector(selectIsLoggedIn); - const dispatch = useDispatch(); - const vcs = useVCS({ workspaceId: state.workspace?._id }); return ( - - {`Duplicate ${state.workspace && getWorkspaceLabel(state.workspace).singular}`} - -
{ - e.preventDefault(); - const data = new FormData(e.currentTarget); - const newName = data.get('name') as string; - const projectId = data.get('projectId') as string; - const duplicateToProject = projects.find(project => project._id === projectId); - if (!duplicateToProject) { - throw new Error('Project could not be found'); - } - if (!state.workspace) { - throw new Error('Workspace could not be found'); - } - const newWorkspace = await workspaceOperations.duplicate(state.workspace, { name: newName, parentId: projectId }); - await models.workspace.ensureChildren(newWorkspace); - // Mark for sync if logged in and in the expected project - if (isLoggedIn && vcs && isRemoteProject(duplicateToProject)) { - await initializeLocalBackendProjectAndMarkForSync({ vcs: vcs.newInstance(), workspace: newWorkspace }); - } - dispatch(activateWorkspace({ workspace: newWorkspace })); - modalRef.current?.hide(); - state?.onDone?.(); - }} - method='post' - id="workspace-duplicate-form" - className="wide pad" - > -
- -
-
- -
-
-
- - - -
+ e.stopPropagation()}> + + {`Duplicate ${workspace && getWorkspaceLabel(workspace).singular}`} + +
{ + modalRef.current?.hide(); + }} + method='post' + action={`/project/${workspace.parentId}/workspace/${workspace._id}/duplicate`} + id="workspace-duplicate-form" + className="wide pad" + > +
+ +
+ +
+ +
+
+
+ + + +
+
); -}); - -WorkspaceDuplicateModal.displayName = 'WorkspaceDuplicateModal'; +}; diff --git a/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx index d9e3be1cde..1026aafe74 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx @@ -11,7 +11,7 @@ import * as workspaceOperations from '../../../models/helpers/workspace-operatio import * as models from '../../../models/index'; import { isRequest } from '../../../models/request'; import { setActiveActivity } from '../../redux/modules/global'; -import { selectActiveApiSpec, selectActiveWorkspace, selectActiveWorkspaceClientCertificates, selectActiveWorkspaceName } from '../../redux/selectors'; +import { selectActiveApiSpec, selectActiveWorkspace, selectActiveWorkspaceClientCertificates, selectActiveWorkspaceName, selectProjects } from '../../redux/selectors'; import { FileInputButton } from '../base/file-input-button'; import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; @@ -20,7 +20,6 @@ import { PromptButton } from '../base/prompt-button'; import { HelpTooltip } from '../help-tooltip'; import { MarkdownEditor } from '../markdown-editor'; import { PasswordViewer } from '../viewers/password-viewer'; -import { showModal } from '.'; import { WorkspaceDuplicateModal } from './workspace-duplicate-modal'; const CertificateFields = styled.div({ @@ -85,8 +84,10 @@ export const WorkspaceSettingsModal = forwardRef Delete
+ {isDuplicateModalOpen && setIsDuplicateModalOpen(false)} />} = ({ hideSettingsModal }) => { const forceToWorkspace = activeWorkspace?._id ? ForceToWorkspace.current : ForceToWorkspace.existing; const workspacesForActiveProject = useSelector(selectWorkspacesForActiveProject); const workspaceRequestsAndRequestGroups = useSelector(selectWorkspaceRequestsAndRequestGroups); - + const { revalidate } = useRevalidator(); const handleImportUri = useCallback(() => { const lastUsedImportUri = window.localStorage.getItem('insomnia.lastUsedImportUri'); const defaultValue = lastUsedImportUri ? { defaultValue: lastUsedImportUri } : {}; @@ -43,12 +44,12 @@ export const ImportExport: FC = ({ hideSettingsModal }) => { placeholder: 'https://website.com/insomnia-import.json', onComplete: (uri: string) => { window.localStorage.setItem('insomnia.lastUsedImportUri', uri); - dispatch(importUri(uri, { workspaceId: activeWorkspace?._id, forceToWorkspace })); + dispatch(importUri(uri, { workspaceId: activeWorkspace?._id, forceToWorkspace, onComplete: revalidate })); hideSettingsModal(); }, ...defaultValue, }); - }, [dispatch, activeWorkspace?._id, forceToWorkspace, hideSettingsModal]); + }, [dispatch, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); const showExportRequestsModal = useCallback(() => { if (!workspaceRequestsAndRequestGroups.filter(r => !isRequestGroup(r)).length) { @@ -68,14 +69,14 @@ export const ImportExport: FC = ({ hideSettingsModal }) => { }, [hideSettingsModal, projectName, workspacesForActiveProject]); const handleImportFile = useCallback(() => { - dispatch(importFile({ workspaceId: activeWorkspace?._id, forceToWorkspace })); + dispatch(importFile({ workspaceId: activeWorkspace?._id, forceToWorkspace, onComplete: revalidate })); hideSettingsModal(); - }, [dispatch, activeWorkspace?._id, forceToWorkspace, hideSettingsModal]); + }, [dispatch, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); const handleImportClipBoard = useCallback(() => { - dispatch(importClipBoard({ workspaceId: activeWorkspace?._id, forceToWorkspace })); + dispatch(importClipBoard({ workspaceId: activeWorkspace?._id, forceToWorkspace, onComplete: revalidate })); hideSettingsModal(); - }, [dispatch, activeWorkspace?._id, forceToWorkspace, hideSettingsModal]); + }, [dispatch, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); const activeWorkspaceName = useSelector(selectActiveWorkspaceName); diff --git a/packages/insomnia/src/ui/components/workspace-card.tsx b/packages/insomnia/src/ui/components/workspace-card.tsx index 764e2e1bbc..f735fa7c4c 100644 --- a/packages/insomnia/src/ui/components/workspace-card.tsx +++ b/packages/insomnia/src/ui/components/workspace-card.tsx @@ -31,6 +31,7 @@ export interface WorkspaceCardProps { specFormatVersion: string | null; hasUnsavedChanges: boolean; onSelect: (workspaceId: string, activity: GlobalActivity) => void; + projects: Project[]; } /** note: numbers are not technically valid (and, indeed, we throw a lint error), but we need to handle this case otherwise a user will not be able to import a spec with a malformed version and even _see_ that it's got the error. */ @@ -40,7 +41,9 @@ export const getVersionDisplayment = (version?: string | number | null) => { } if (typeof version === 'number') { - console.warn(`OpenAPI documents must not use number data types for $.info.version, found ${version}`); + console.warn( + `OpenAPI documents must not use number data types for $.info.version, found ${version}` + ); version = String(version); } else if (typeof version !== 'string') { console.error('unable to parse spec version'); @@ -68,6 +71,7 @@ export const WorkspaceCard: FC = ({ specFormat, specFormatVersion, hasUnsavedChanges, + projects, onSelect, }) => { let branch = lastActiveBranch; @@ -81,10 +85,7 @@ export const WorkspaceCard: FC = ({ if (modifiedLocally) { log = ( - {' '} + {' '} (unsaved) ); @@ -98,13 +99,6 @@ export const WorkspaceCard: FC = ({ ); } - const docMenu = ( - - ); const version = getVersionDisplayment(spec?.info?.version); let label: string = strings.collection.singular; @@ -129,10 +123,14 @@ export const WorkspaceCard: FC = ({ } // Filter the card by multiple different properties - const matchResults = fuzzyMatchAll(filter, [title, label, branch || '', version || ''], { - splitSpace: true, - loose: true, - }); + const matchResults = fuzzyMatchAll( + filter, + [title, label, branch || '', version || ''], + { + splitSpace: true, + loose: true, + } + ); // Return null if we don't match the filter if (filter && !matchResults) { @@ -141,17 +139,30 @@ export const WorkspaceCard: FC = ({ return ( : undefined} + docBranch={ + branch ? : undefined + } docTitle={title ? : undefined} - docVersion={version ? : undefined} - tagLabel={label ? ( - <> - {labelIcon} - - - ) : undefined} + docVersion={ + version ? : undefined + } + tagLabel={ + label ? ( + <> + {labelIcon} + + + ) : undefined + } docLog={log} - docMenu={docMenu} + docMenu={ + + } docFormat={format} onClick={() => onSelect(workspace._id, defaultActivity)} /> diff --git a/packages/insomnia/src/ui/hooks/use-vcs.ts b/packages/insomnia/src/ui/hooks/use-vcs.ts index 73f8ab4055..9388344855 100644 --- a/packages/insomnia/src/ui/hooks/use-vcs.ts +++ b/packages/insomnia/src/ui/hooks/use-vcs.ts @@ -4,12 +4,10 @@ import { getDataDirectory } from '../../common/electron-helpers'; import { generateId } from '../../common/misc'; import FileSystemDriver from '../../sync/store/drivers/file-system-driver'; import { type MergeConflict } from '../../sync/types'; -import { VCS } from '../../sync/vcs/vcs'; +import { getVCS, initVCS, VCS } from '../../sync/vcs/vcs'; import { showModal } from '../components/modals/index'; import { SyncMergeModal } from '../components/modals/sync-merge-modal'; -let vcsInstance: VCS | null = null; - export function useVCS({ workspaceId, }: { @@ -27,10 +25,11 @@ export function useVCS({ setVCS(null); async function updateVCS() { + let vcsInstance = getVCS(); if (!vcsInstance) { const driver = FileSystemDriver.create(getDataDirectory()); - vcsInstance = new VCS(driver, async conflicts => { + vcsInstance = await initVCS(driver, async conflicts => { return new Promise(resolve => { showModal(SyncMergeModal, { conflicts, diff --git a/packages/insomnia/src/ui/hooks/workspace.ts b/packages/insomnia/src/ui/hooks/workspace.ts deleted file mode 100644 index 32cab26e00..0000000000 --- a/packages/insomnia/src/ui/hooks/workspace.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Reducer, useCallback, useMemo, useReducer } from 'react'; -import { useSelector } from 'react-redux'; -import { useAsync } from 'react-use'; - -import { isRemoteProject } from '../../models/project'; -import { BackendProject } from '../../sync/types'; -import { BackendProjectWithTeam } from '../../sync/vcs/normalize-backend-project-team'; -import { pullBackendProject } from '../../sync/vcs/pull-backend-project'; -import { VCS } from '../../sync/vcs/vcs'; -import { showError } from '../components/modals'; -import { selectActiveProject, selectIsLoggedIn, selectRemoteProjects, selectSettings, selectWorkspaces } from '../redux/selectors'; -import { useSafeReducerDispatch } from './use-safe-reducer-dispatch'; - -interface State { - loading: boolean; - localBackendProjects: BackendProject[]; - remoteBackendProjects: BackendProjectWithTeam[]; - pullingBackendProjects: Record; -} - -const initialState: State = { - loading: false, - localBackendProjects: [], - remoteBackendProjects: [], - pullingBackendProjects: {}, -}; - -type Action = - | { type: 'loadBackendProjects' } - | { type: 'saveBackendProjects'; local: State['localBackendProjects']; remote: State['remoteBackendProjects']} - | { type: 'startPullingBackendProject'; backendProjectId: string } - | { type: 'stopPullingBackendProject'; backendProjectId: string }; - -const reducer: Reducer = (prevState, action) => { - switch (action.type) { - case 'loadBackendProjects': - return { ...prevState, loading: true }; - case 'saveBackendProjects': - return { ...prevState, localBackendProjects: action.local, remoteBackendProjects: action.remote, loading: false }; - case 'startPullingBackendProject': - return { ...prevState, pullingBackendProjects: { ...prevState.pullingBackendProjects, [action.backendProjectId]: true } }; - case 'stopPullingBackendProject': - return { ...prevState, pullingBackendProjects: { ...prevState.pullingBackendProjects, [action.backendProjectId]: false } }; - default: - return prevState; - } -}; - -export const useRemoteWorkspaces = (vcs?: VCS) => { - // Fetch from redux - const workspaces = useSelector(selectWorkspaces); - const activeProject = useSelector(selectActiveProject); - const remoteProjects = useSelector(selectRemoteProjects); - const isLoggedIn = useSelector(selectIsLoggedIn); - const { incognitoMode } = useSelector(selectSettings); - - // Local state - const [{ loading, localBackendProjects, remoteBackendProjects, pullingBackendProjects }, _dispatch] = useReducer(reducer, initialState); - const dispatch = useSafeReducerDispatch(_dispatch); - - // Refresh remote backend project - const refresh = useCallback(async () => { - if (!vcs || !isLoggedIn || !isRemoteProject(activeProject)) { - return; - } - - dispatch({ type: 'loadBackendProjects' }); - const remoteBackendProjects = await vcs.remoteBackendProjects(activeProject.remoteId); - const localBackendProjects = await vcs.localBackendProjects(); - dispatch({ type: 'saveBackendProjects', local: localBackendProjects, remote: remoteBackendProjects }); - }, [vcs, isLoggedIn, activeProject, dispatch]); - - // Find remote backend project that haven't been pulled - const missingBackendProjects = useMemo(() => remoteBackendProjects.filter(({ id, rootDocumentId }) => { - const localBackendProjectExists = localBackendProjects.find(p => p.id === id); - const workspaceExists = workspaces.find(w => w._id === rootDocumentId); - // Mark as missing if: - // - the backend project doesn't yet exists locally - // - the backend project exists locally but somehow the workspace doesn't anymore - return !(workspaceExists && localBackendProjectExists); - }), [localBackendProjects, remoteBackendProjects, workspaces]); - - // Pull a remote backend project - const pull = useCallback(async (backendProject: BackendProjectWithTeam) => { - if (!vcs) { - throw new Error('VCS is not defined'); - } - - dispatch({ type: 'startPullingBackendProject', backendProjectId: backendProject.id }); - - try { - // Clone old VCS so we don't mess anything up while working on other backend projects - const newVCS = vcs.newInstance(); - // Remove all backend projects for workspace first - await newVCS.removeBackendProjectsForRoot(backendProject.rootDocumentId); - - await pullBackendProject({ vcs: newVCS, backendProject, remoteProjects }); - - await refresh(); - } catch (err) { - showError({ - title: 'Pull Error', - message: `Failed to pull workspace. ${err.message}`, - error: err, - }); - } finally { - dispatch({ type: 'stopPullingBackendProject', backendProjectId: backendProject.id }); - } - }, [vcs, refresh, remoteProjects, dispatch]); - - // If the refresh callback changes, refresh - useAsync(async () => { - if (!incognitoMode) { - await refresh(); - } - }, [refresh, incognitoMode]); - - return { - loading, - missingBackendProjects, - pullingBackendProjects, - refresh, - pull, - }; -}; diff --git a/packages/insomnia/src/ui/index.tsx b/packages/insomnia/src/ui/index.tsx index 6a6bf0e7fd..6abd5dacc7 100644 --- a/packages/insomnia/src/ui/index.tsx +++ b/packages/insomnia/src/ui/index.tsx @@ -3,20 +3,38 @@ import React, { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import './rendererListeners'; -import { ACTIVITY_DEBUG, ACTIVITY_HOME, ACTIVITY_SPEC, ACTIVITY_UNIT_TEST, getProductName, isDevelopment } from '../common/constants'; +import { + ACTIVITY_DEBUG, + ACTIVITY_HOME, + ACTIVITY_SPEC, + ACTIVITY_UNIT_TEST, + getProductName, + isDevelopment, +} from '../common/constants'; import { database } from '../common/database'; import { initializeLogging } from '../common/log'; import * as models from '../models'; import { initNewOAuthSession } from '../network/o-auth-2/misc'; import { init as initPlugins } from '../plugins'; import { applyColorScheme } from '../plugins/misc'; -import { init as initStore } from './redux/modules'; +import { init as initStore, RootState } from './redux/modules'; import { initializeSentry } from './sentry'; -import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { + createMemoryRouter, + RouterProvider, + matchPath, +} from 'react-router-dom'; import Root from './routes/root'; import './css/index.less'; // this import must come after `Root`. import { AppLoadingIndicator } from './components/app-loading-indicator'; +import { + setActiveProject, + setActiveWorkspace, + setActiveActivity, +} from './redux/modules/global'; +import { DEFAULT_PROJECT_ID } from '../models/project'; +import { ErrorRoute } from './routes/error'; const Project = lazy(() => import('./routes/project')); const UnitTest = lazy(() => import('./routes/unit-test')); const Debug = lazy(() => import('./routes/debug')); @@ -28,51 +46,146 @@ initializeLogging(); document.body.setAttribute('data-platform', process.platform); document.title = getProductName(); +const locationHistoryEntry = localStorage.getItem('locationHistoryEntry'); + const router = createMemoryRouter( // @TODO - Investigate file based routing to generate these routes: [ { path: '/', element: , - errorElement:
Error
, + errorElement: , children: [ { - path: ACTIVITY_HOME, - element: ( - }> - - - ), - }, - { - path: ACTIVITY_DEBUG, - element: ( - }> - - - ), - }, - { - path: ACTIVITY_SPEC, - element: ( - }> - - - ), - }, - { - path: ACTIVITY_UNIT_TEST, - element: ( - }> - - - ), + path: 'project', + children: [ + { + path: ':projectId', + id: '/project/:projectId', + loader: async (...args) => + (await import('./routes/project')).loader(...args), + element: ( + }> + + + ), + children: [ + { + path: 'delete', + action: async (...args) => + (await import('./routes/actions')).deleteProjectAction( + ...args + ), + }, + { + path: 'rename', + action: async (...args) => + (await import('./routes/actions')).renameProjectAction( + ...args + ), + }, + ], + }, + { + path: ':projectId/workspace', + children: [ + { + path: ':workspaceId', + children: [ + { + path: `${ACTIVITY_DEBUG}`, + element: ( + }> + + + ), + }, + { + path: `${ACTIVITY_SPEC}`, + element: ( + }> + + + ), + }, + { + path: `${ACTIVITY_UNIT_TEST}`, + element: ( + }> + + + ), + }, + { + path: 'duplicate', + action: async (...args) => + ( + await import('./routes/actions') + ).duplicateWorkspaceAction(...args), + }, + ], + }, + { + path: 'new', + action: async (...args) => + (await import('./routes/actions')).createNewWorkspaceAction( + ...args + ), + }, + { + path: 'delete', + action: async (...args) => + (await import('./routes/actions')).deleteWorkspaceAction( + ...args + ), + }, + { + path: 'update', + action: async (...args) => + (await import('./routes/actions')).updateWorkspaceAction( + ...args + ), + }, + ], + }, + { + path: 'new', + action: async (...args) => + (await import('./routes/actions')).createNewProjectAction( + ...args + ), + }, + { + path: ':projectId/remote-collections', + loader: async (...args) => + ( + await import('./routes/remote-collections') + ).remoteCollectionsLoader(...args), + children: [ + { + path: 'pull', + action: async (...args) => + ( + await import('./routes/remote-collections') + ).pullRemoteCollectionAction(...args), + }, + ], + }, + ], }, ], }, - ] + ], + { + initialEntries: [locationHistoryEntry || `/project/${DEFAULT_PROJECT_ID}`], + } ); +// Store the last location in local storage +router.subscribe(({ location }) => { + localStorage.setItem('locationHistoryEntry', location.pathname); +}); + async function renderApp() { await database.initClient(); @@ -88,7 +201,109 @@ async function renderApp() { // Create Redux store const store = await initStore(); + // Synchronizes the Redux store with the router history + // @HACK: This is temporary until we completely remove navigation through Redux + const synchronizeRouterState = () => { + let currentActivity = (store.getState() as RootState).global.activeActivity; + let currentPathname = router.state.location.pathname; + router.subscribe(({ location }) => { + if (location.pathname !== currentPathname) { + currentPathname = location.pathname; + const isActivityHome = matchPath( + { + path: '/project/:projectId', + }, + location.pathname + ); + + const isActivityDebug = matchPath( + { + path: `/project/:projectId/workspace/:workspaceId/${ACTIVITY_DEBUG}`, + end: false, + }, + location.pathname + ); + + const isActivityDesign = matchPath( + { + path: `/project/:projectId/workspace/:workspaceId/${ACTIVITY_SPEC}`, + end: false, + }, + location.pathname + ); + + const isActivityTest = matchPath( + { + path: `/project/:projectId/workspace/:workspaceId/${ACTIVITY_UNIT_TEST}`, + end: false, + }, + location.pathname + ); + + if (isActivityDebug) { + currentActivity = ACTIVITY_DEBUG; + store.dispatch( + setActiveProject(isActivityDebug?.params.projectId || '') + ); + store.dispatch( + setActiveWorkspace(isActivityDebug?.params.workspaceId || '') + ); + store.dispatch(setActiveActivity(ACTIVITY_DEBUG)); + } else if (isActivityDesign) { + currentActivity = ACTIVITY_SPEC; + store.dispatch( + setActiveProject(isActivityDesign?.params.projectId || '') + ); + store.dispatch( + setActiveWorkspace(isActivityDesign?.params.workspaceId || '') + ); + store.dispatch(setActiveActivity(ACTIVITY_SPEC)); + } else if (isActivityTest) { + currentActivity = ACTIVITY_UNIT_TEST; + store.dispatch( + setActiveProject(isActivityTest?.params.projectId || '') + ); + store.dispatch( + setActiveWorkspace(isActivityTest?.params.workspaceId || '') + ); + store.dispatch(setActiveActivity(ACTIVITY_UNIT_TEST)); + } else { + currentActivity = ACTIVITY_HOME; + store.dispatch( + setActiveProject(isActivityHome?.params.projectId || '') + ); + store.dispatch(setActiveActivity(ACTIVITY_HOME)); + } + } + }); + + store.subscribe(() => { + const state = store.getState() as RootState; + const activity = state.global.activeActivity; + + if (activity !== currentActivity) { + currentActivity = activity; + if (activity === ACTIVITY_HOME) { + router.navigate(`/project/${state.global.activeProjectId}`); + } else if (activity === ACTIVITY_DEBUG) { + router.navigate( + `/project/${state.global.activeProjectId}/workspace/${state.global.activeWorkspaceId}/${ACTIVITY_DEBUG}` + ); + } else if (activity === ACTIVITY_SPEC) { + router.navigate( + `/project/${state.global.activeProjectId}/workspace/${state.global.activeWorkspaceId}/${ACTIVITY_SPEC}` + ); + } else if (activity === ACTIVITY_UNIT_TEST) { + router.navigate( + `/project/${state.global.activeProjectId}/workspace/${state.global.activeWorkspaceId}/${ACTIVITY_UNIT_TEST}` + ); + } + } + }); + }; + + synchronizeRouterState(); const root = document.getElementById('root'); if (!root) { diff --git a/packages/insomnia/src/ui/redux/modules/import.ts b/packages/insomnia/src/ui/redux/modules/import.ts index 1ac3e97113..e3fb753f59 100644 --- a/packages/insomnia/src/ui/redux/modules/import.ts +++ b/packages/insomnia/src/ui/redux/modules/import.ts @@ -22,6 +22,7 @@ export interface ImportOptions { forceToProject?: 'active' | 'prompt'; forceToWorkspace?: ForceToWorkspace; forceToScope?: WorkspaceScope; + onComplete?: () => void; } const handleImportResult = (result: ImportResult, errorMessage: string) => { @@ -113,6 +114,8 @@ export const importFile = ( dispatch(loadStop()); } } + + options.onComplete?.(); }; export const readFromClipBoard = () => { @@ -150,6 +153,8 @@ export const importClipBoard = ( } finally { dispatch(loadStop()); } + + options.onComplete?.(); }; export const importUri = ( @@ -169,4 +174,6 @@ export const importUri = ( } finally { dispatch(loadStop()); } + + options.onComplete?.(); }; diff --git a/packages/insomnia/src/ui/routes/actions.tsx b/packages/insomnia/src/ui/routes/actions.tsx new file mode 100644 index 0000000000..ee80815bd0 --- /dev/null +++ b/packages/insomnia/src/ui/routes/actions.tsx @@ -0,0 +1,213 @@ +import { invariant } from '@remix-run/router'; +import { ActionFunction, redirect } from 'react-router-dom'; + +import * as session from '../../account/session'; +import { SegmentEvent, trackSegmentEvent } from '../../common/analytics'; +import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants'; +import { database } from '../../common/database'; +import * as models from '../../models'; +import * as workspaceOperations from '../../models/helpers/workspace-operations'; +import { DEFAULT_PROJECT_ID, isRemoteProject } from '../../models/project'; +import { isCollection } from '../../models/workspace'; +import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project'; +import { getVCS } from '../../sync/vcs/vcs'; +// Project +export const createNewProjectAction: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const name = formData.get('name'); + invariant(typeof name === 'string', 'Name is required'); + const project = await models.project.create({ name }); + trackSegmentEvent(SegmentEvent.projectLocalCreate); + return redirect(`/project/${project._id}`); +}; + +export const renameProjectAction: ActionFunction = async ({ + request, + params, +}) => { + const formData = await request.formData(); + + const name = formData.get('name'); + invariant(typeof name === 'string', 'Name is required'); + + const { projectId } = params; + invariant(typeof projectId === 'string', 'Project ID is required'); + + const project = await models.project.getById(projectId); + + invariant(project, 'Project not found'); + + invariant( + !isRemoteProject(project), + 'Cannot rename remote project', + ); + + await models.project.update(project, { name }); +}; + +export const deleteProjectAction: ActionFunction = async ({ params }) => { + const { projectId } = params; + invariant(typeof projectId === 'string', 'Project ID is required'); + const project = await models.project.getById(projectId); + invariant(project, 'Project not found'); + + await models.stats.incrementDeletedRequestsForDescendents(project); + await models.project.remove(project); + + trackSegmentEvent(SegmentEvent.projectLocalDelete); + return redirect(`/project/${DEFAULT_PROJECT_ID}`); +}; + +// Workspace +export const createNewWorkspaceAction: ActionFunction = async ({ + params, + request, +}) => { + const { projectId } = params; + + invariant(typeof projectId === 'string', 'Project ID is required'); + + const project = await models.project.getById(projectId); + + invariant(project, 'Project not found'); + + const formData = await request.formData(); + + const name = formData.get('name'); + invariant(typeof name === 'string', 'Name is required'); + + const scope = formData.get('scope'); + invariant(scope === 'design' || scope === 'collection', 'Scope is required'); + + const flushId = await database.bufferChanges(); + + const workspace = await models.workspace.create({ + name, + scope, + parentId: projectId, + }); + await models.workspace.ensureChildren(workspace); + await models.workspaceMeta.getOrCreateByParentId(workspace._id); + + await database.flushChanges(flushId); + if (session.isLoggedIn() && isRemoteProject(project)) { + if (!isCollection(workspace)) { + // Don't initialize and mark for sync unless we're in a collection + return; + } + + const vcs = getVCS(); + if (vcs) { + initializeLocalBackendProjectAndMarkForSync({ + vcs, + workspace, + }); + } + } + + trackSegmentEvent( + isCollection(workspace) + ? SegmentEvent.collectionCreate + : SegmentEvent.documentCreate + ); + + return redirect( + `/project/${projectId}/workspace/${workspace._id}/${ + workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC + }` + ); +}; + +export const deleteWorkspaceAction: ActionFunction = async ({ + params, + request, +}) => { + const { projectId } = params; + invariant(projectId, 'projectId is required'); + + const project = await models.project.getById(projectId); + invariant(project, 'Project not found'); + + const formData = await request.formData(); + + const workspaceId = formData.get('workspaceId'); + invariant(typeof workspaceId === 'string', 'Workspace ID is required'); + + const workspace = await models.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + + await models.stats.incrementDeletedRequestsForDescendents(workspace); + await models.workspace.remove(workspace); + + return redirect(`/project/${projectId}`); +}; + +export const duplicateWorkspaceAction: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const projectId = formData.get('projectId'); + invariant(typeof projectId === 'string', 'Project ID is required'); + + const workspaceId = formData.get('workspaceId'); + invariant(typeof workspaceId === 'string', 'Workspace ID is required'); + + const workspace = await models.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + + const name = formData.get('name') || ''; + invariant(typeof name === 'string', 'Name is required'); + + const duplicateToProject = await models.project.getById(projectId); + invariant(duplicateToProject, 'Project not found'); + + const newWorkspace = await workspaceOperations.duplicate(workspace, { + name, + parentId: projectId, + }); + await models.workspace.ensureChildren(newWorkspace); + + // Mark for sync if logged in and in the expected project + const vcs = getVCS(); + if (session.isLoggedIn() && vcs && isRemoteProject(duplicateToProject)) { + await initializeLocalBackendProjectAndMarkForSync({ + vcs: vcs.newInstance(), + workspace: newWorkspace, + }); + } + + return redirect( + `/project/${projectId}/workspace/${newWorkspace._id}/${ + newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC + }` + ); +}; + +export const updateWorkspaceAction: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const workspaceId = formData.get('workspaceId'); + invariant(typeof workspaceId === 'string', 'Workspace ID is required'); + + const name = formData.get('name'); + invariant(typeof name === 'string', 'Name is required'); + + const description = formData.get('description'); + if (description) { + invariant(typeof description === 'string', 'Description is required'); + } + + const workspace = await models.workspace.getById(workspaceId); + invariant(workspace, 'Workspace not found'); + + if (workspace.scope === 'design') { + const apiSpec = await models.apiSpec.getByParentId(workspaceId); + invariant(apiSpec, 'No Api Spec found for this workspace'); + + await models.apiSpec.update(apiSpec, { + fileName: name, + }); + } + + await models.workspace.update(workspace, { + name, + description: description || workspace.description, + }); +}; diff --git a/packages/insomnia/src/ui/routes/error.tsx b/packages/insomnia/src/ui/routes/error.tsx new file mode 100644 index 0000000000..0a8d9fe462 --- /dev/null +++ b/packages/insomnia/src/ui/routes/error.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { FC } from 'react'; +import { + isRouteErrorResponse, + useNavigate, + useNavigation, + useRouteError, +} from 'react-router-dom'; +import styled from 'styled-components'; + +import { DEFAULT_PROJECT_ID } from '../../models/project'; +import { Mailto } from '../components/base/mailto'; +import { Button } from '../components/themed-button'; + +const Container = styled.div({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + width: '100%', +}); + +const Spinner = () => ; + +export const ErrorRoute: FC = () => { + const error = useRouteError(); + const getErrorMessage = (err: unknown) => { + if (isRouteErrorResponse(err)) { + return err.data; + } + if (err instanceof Error) { + return err.message; + } + + return 'Unknown error'; + }; + + const navigate = useNavigate(); + const navigation = useNavigation(); + + const errorMessage = getErrorMessage(error); + + return ( + +

Application Error

+

+ Failed to render. Please send the following error to{' '} + + . +

+

Message:

+
{errorMessage}
+ +
+ ); +}; diff --git a/packages/insomnia/src/ui/routes/modals.tsx b/packages/insomnia/src/ui/routes/modals.tsx new file mode 100644 index 0000000000..9436cd78d5 --- /dev/null +++ b/packages/insomnia/src/ui/routes/modals.tsx @@ -0,0 +1,239 @@ +import React, { FC, Fragment } from 'react'; +import { useSelector } from 'react-redux'; + +import { ErrorBoundary } from '../components/error-boundary'; +import { registerModal } from '../components/modals'; +import { AddKeyCombinationModal } from '../components/modals/add-key-combination-modal'; +import { AlertModal } from '../components/modals/alert-modal'; +import { AnalyticsModal } from '../components/modals/analytics-modal'; +import { AskModal } from '../components/modals/ask-modal'; +import { CodePromptModal } from '../components/modals/code-prompt-modal'; +import { CookieModifyModal } from '../components/modals/cookie-modify-modal'; +import { CookiesModal } from '../components/modals/cookies-modal'; +import { EnvironmentEditModal } from '../components/modals/environment-edit-modal'; +import { ErrorModal } from '../components/modals/error-modal'; +import { ExportRequestsModal } from '../components/modals/export-requests-modal'; +import { FilterHelpModal } from '../components/modals/filter-help-modal'; +import { GenerateCodeModal } from '../components/modals/generate-code-modal'; +import { GenerateConfigModal } from '../components/modals/generate-config-modal'; +import { GitBranchesModal } from '../components/modals/git-branches-modal'; +import { GitLogModal } from '../components/modals/git-log-modal'; +import { GitRepositorySettingsModal } from '../components/modals/git-repository-settings-modal'; +import { GitStagingModal } from '../components/modals/git-staging-modal'; +import { LoginModal } from '../components/modals/login-modal'; +import { NunjucksModal } from '../components/modals/nunjucks-modal'; +import { PromptModal } from '../components/modals/prompt-modal'; +import { ProtoFilesModal } from '../components/modals/proto-files-modal'; +import { RequestGroupSettingsModal } from '../components/modals/request-group-settings-modal'; +import { RequestRenderErrorModal } from '../components/modals/request-render-error-modal'; +import { RequestSettingsModal } from '../components/modals/request-settings-modal'; +import { RequestSwitcherModal } from '../components/modals/request-switcher-modal'; +import { ResponseDebugModal } from '../components/modals/response-debug-modal'; +import { SelectModal } from '../components/modals/select-modal'; +import { SettingsModal } from '../components/modals/settings-modal'; +import { SyncBranchesModal } from '../components/modals/sync-branches-modal'; +import { SyncDeleteModal } from '../components/modals/sync-delete-modal'; +import { SyncHistoryModal } from '../components/modals/sync-history-modal'; +import { SyncMergeModal } from '../components/modals/sync-merge-modal'; +import { SyncStagingModal } from '../components/modals/sync-staging-modal'; +import { WorkspaceEnvironmentsEditModal } from '../components/modals/workspace-environments-edit-modal'; +import { WorkspaceSettingsModal } from '../components/modals/workspace-settings-modal'; +import { WrapperModal } from '../components/modals/wrapper-modal'; +import { GrpcDispatchModalWrapper } from '../context/grpc'; +import { useGitVCS } from '../hooks/use-git-vcs'; +import { useVCS } from '../hooks/use-vcs'; +import { + selectActiveApiSpec, + selectActiveCookieJar, + selectActiveEnvironment, + selectActiveGitRepository, + selectActiveProject, + selectActiveWorkspace, +} from '../redux/selectors'; + +const Modals: FC = () => { + const activeCookieJar = useSelector(selectActiveCookieJar); + const activeApiSpec = useSelector(selectActiveApiSpec); + const activeWorkspace = useSelector(selectActiveWorkspace); + const activeGitRepository = useSelector(selectActiveGitRepository); + const activeEnvironment = useSelector(selectActiveEnvironment); + const activeProject = useSelector(selectActiveProject); + + const vcs = useVCS({ + workspaceId: activeWorkspace?._id, + }); + + const gitVCS = useGitVCS({ + workspaceId: activeWorkspace?._id, + projectId: activeProject?._id, + gitRepository: activeGitRepository, + }); + + return ( +
+ + + registerModal(instance, 'AlertModal')} /> + registerModal(instance, 'ErrorModal')} /> + registerModal(instance, 'PromptModal')} + /> + registerModal(instance, 'WrapperModal')} + /> + + registerModal(instance, 'AskModal')} /> + registerModal(instance, 'SelectModal')} + /> + registerModal(instance, 'FilterHelpModal')} + /> + registerModal(instance, 'RequestRenderErrorModal')} + /> + registerModal(instance, 'GenerateConfigModal')} + /> + + registerModal(instance, 'CodePromptModal')} + /> + registerModal(instance, 'RequestSettingsModal')} + /> + + registerModal(instance, 'RequestGroupSettingsModal') + } + /> + + {activeWorkspace ? ( + <> + {/* TODO: Figure out why cookieJar is sometimes null */} + {activeCookieJar ? ( + <> + registerModal(instance, 'CookiesModal')} + /> + + registerModal(instance, 'CookieModifyModal') + } + /> + + ) : null} + + registerModal(instance, 'NunjucksModal')} + workspace={activeWorkspace} + /> + + {activeApiSpec ? ( + + registerModal(instance, 'WorkspaceSettingsModal') + } + /> + ) : null} + + ) : null} + + registerModal(instance, 'GenerateCodeModal')} + environmentId={activeEnvironment ? activeEnvironment._id : 'n/a'} + /> + + registerModal(instance, 'SettingsModal')} + /> + registerModal(instance, 'ResponseDebugModal')} + /> + + registerModal(instance, 'RequestSwitcherModal')} + /> + + registerModal(instance, 'EnvironmentEditModal')} + /> + + + registerModal(instance, 'GitRepositorySettingsModal') + } + /> + + {activeWorkspace && gitVCS ? ( + + registerModal(instance, 'GitStagingModal')} + workspace={activeWorkspace} + vcs={gitVCS} + gitRepository={activeGitRepository} + /> + registerModal(instance, 'GitLogModal')} + vcs={gitVCS} + /> + {activeGitRepository !== null && ( + registerModal(instance, 'GitBranchesModal')} + vcs={gitVCS} + /> + )} + + ) : null} + + {activeWorkspace && vcs ? ( + + registerModal(instance, 'SyncStagingModal')} + vcs={vcs} + /> + registerModal(instance, 'SyncMergeModal')} + /> + registerModal(instance, 'SyncBranchesModal')} + vcs={vcs} + /> + registerModal(instance, 'SyncDeleteModal')} + vcs={vcs} + /> + registerModal(instance, 'SyncHistoryModal')} + vcs={vcs} + /> + + ) : null} + + + registerModal(instance, 'WorkspaceEnvironmentsEditModal') + } + /> + + registerModal(instance, 'AddKeyCombinationModal')} + /> + registerModal(instance, 'ExportRequestsModal')} + /> + + + {dispatch => ( + registerModal(instance, 'ProtoFilesModal')} + grpcDispatch={dispatch} + /> + )} + + +
+ ); +}; + +export default Modals; diff --git a/packages/insomnia/src/ui/routes/project.tsx b/packages/insomnia/src/ui/routes/project.tsx index 19b48855b0..a3b60a5173 100644 --- a/packages/insomnia/src/ui/routes/project.tsx +++ b/packages/insomnia/src/ui/routes/project.tsx @@ -1,22 +1,35 @@ -import 'swagger-ui-react/swagger-ui.css'; - -import React, { FC, useCallback, useState } from 'react'; +import { invariant } from '@remix-run/router'; +import React, { FC, Fragment, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { + LoaderFunction, + useFetcher, + useLoaderData, + useNavigate, + useRevalidator, + useSearchParams, + useSubmit, +} from 'react-router-dom'; import styled from 'styled-components'; -import { unreachableCase } from 'ts-assert-unreachable'; +import { isLoggedIn } from '../../account/session'; import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs'; import { + ACTIVITY_DEBUG, + ACTIVITY_SPEC, DashboardSortOrder, } from '../../common/constants'; -import { isNotNullOrUndefined } from '../../common/misc'; +import { database } from '../../common/database'; +import { fuzzyMatchAll, isNotNullOrUndefined } from '../../common/misc'; import { descendingNumberSort, sortMethodMap } from '../../common/sorting'; +import * as models from '../../models'; import { ApiSpec } from '../../models/api-spec'; -import { isRemoteProject } from '../../models/project'; -import { isDesign, Workspace, WorkspaceScopeKeys } from '../../models/workspace'; -import { WorkspaceMeta } from '../../models/workspace-meta'; +import { sortProjects } from '../../models/helpers/project'; +import { isRemoteProject, Project } from '../../models/project'; +import { isDesign, Workspace } from '../../models/workspace'; import { MemClient } from '../../sync/git/mem-client'; -import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project'; +import { initializeProjectFromTeam } from '../../sync/vcs/initialize-model-from'; +import { getVCS } from '../../sync/vcs/vcs'; import { AppHeader } from '../components/app-header'; import { Dropdown } from '../components/base/dropdown/dropdown'; import { DropdownButton } from '../components/base/dropdown/dropdown-button'; @@ -30,14 +43,15 @@ import { PageLayout } from '../components/page-layout'; import { WrapperHomeEmptyStatePane } from '../components/panes/wrapper-home-empty-state-pane'; import { SvgIcon } from '../components/svg-icon'; import { Button } from '../components/themed-button'; -import { WorkspaceCard, WorkspaceCardProps } from '../components/workspace-card'; -import { useVCS } from '../hooks/use-vcs'; +import { WorkspaceCard } from '../components/workspace-card'; import { cloneGitRepository } from '../redux/modules/git'; -import { selectIsLoading, setDashboardSortOrder } from '../redux/modules/global'; +import { selectIsLoading } from '../redux/modules/global'; import { ForceToWorkspace } from '../redux/modules/helpers'; -import { importClipBoard, importFile, importUri } from '../redux/modules/import'; -import { activateWorkspace, createWorkspace } from '../redux/modules/workspace'; -import { selectActiveProject, selectApiSpecs, selectDashboardSortOrder, selectIsLoggedIn, selectWorkspaceMetas, selectWorkspacesForActiveProject } from '../redux/selectors'; +import { + importClipBoard, + importFile, + importUri, +} from '../redux/modules/import'; const CreateButton = styled(Button).attrs({ variant: 'contained', @@ -54,141 +68,253 @@ const CardContainer = styled.div({ paddingTop: 'var(--padding-md)', }); -function orderDashboardCards(orderBy: DashboardSortOrder) { - return (cardA: Pick, cardB: Pick) => { - switch (orderBy) { - case 'modified-desc': - return sortMethodMap['modified-desc'](cardA, cardB); - case 'name-asc': - return sortMethodMap['name-asc'](cardA.workspace, cardB.workspace); - case 'name-desc': - return sortMethodMap['name-desc'](cardA.workspace, cardB.workspace); - case 'created-asc': - return sortMethodMap['created-asc'](cardA.workspace, cardB.workspace); - case 'created-desc': - return sortMethodMap['created-desc'](cardA.workspace, cardB.workspace); - default: - return unreachableCase(orderBy, `Dashboard ordering "${orderBy}" is invalid`); - } - }; +interface WorkspaceWithMetadata { + hasUnsavedChanges: boolean; + lastModifiedTimestamp: number; + modifiedLocally: number; + lastCommitTime: number | null | undefined; + lastCommitAuthor: string | null | undefined; + lastActiveBranch: string | null | undefined; + spec: Record | null; + specFormat: 'openapi' | 'swagger' | null; + name: string; + apiSpec: ApiSpec; + specFormatVersion: string | null; + workspace: Workspace; } -const mapWorkspaceToWorkspaceCard = ({ - apiSpecs, - workspaceMetas, -}: { - apiSpecs: ApiSpec[]; - workspaceMetas: WorkspaceMeta[]; -}) => (workspace: Workspace) => { - const apiSpec = apiSpecs.find(s => s.parentId === workspace._id); +interface LoaderData { + workspaces: WorkspaceWithMetadata[]; + activeProject: Project; + projects: Project[]; +} - // an apiSpec model will always exist because a migration in the workspace forces it to - if (!apiSpec) { - return null; +export const loader: LoaderFunction = async ({ + params, + request, +}): Promise => { + const search = new URL(request.url).searchParams; + const { projectId } = params; + invariant(projectId, 'projectId parameter is required'); + + const sortOrder = search.get('sortOrder') || 'modified-desc'; + const filter = search.get('filter') || ''; + + const project = await models.project.getById(projectId); + + invariant(project, 'Project was not found'); + + const projectWorkspaces = await models.workspace.findByParentId(project._id); + + const getWorkspaceMetaData = async (workspace: Workspace) => { + const apiSpec = await models.apiSpec.getByParentId(workspace._id); + invariant(apiSpec, 'ApiSpec should exist for workspace'); + + let spec: ParsedApiSpec['contents'] = null; + let specFormat: ParsedApiSpec['format'] = null; + let specFormatVersion: ParsedApiSpec['formatVersion'] = null; + + try { + const result = parseApiSpec(apiSpec.contents); + spec = result.contents; + specFormat = result.format; + specFormatVersion = result.formatVersion; + } catch (err) { + // Assume there is no spec + // TODO: Check for parse errors if it's an invalid spec + } + + const workspaceMeta = await models.workspaceMeta.getByParentId( + workspace._id + ); + const lastActiveBranch = workspaceMeta?.cachedGitRepositoryBranch; + + const lastCommitAuthor = workspaceMeta?.cachedGitLastAuthor; + + // WorkspaceMeta is a good proxy for last modified time + const workspaceModified = workspaceMeta?.modified || workspace.modified; + + const modifiedLocally = isDesign(workspace) + ? apiSpec.modified + : workspaceModified; + + // Span spec, workspace and sync related timestamps for card last modified label and sort order + const lastModifiedFrom = [ + workspace?.modified, + workspaceMeta?.modified, + apiSpec.modified, + workspaceMeta?.cachedGitLastCommitTime, + ]; + + const lastModifiedTimestamp = lastModifiedFrom + .filter(isNotNullOrUndefined) + .sort(descendingNumberSort)[0]; + + const hasUnsavedChanges = Boolean( + isDesign(workspace) && + workspaceMeta?.cachedGitLastCommitTime && + apiSpec.modified > workspaceMeta?.cachedGitLastCommitTime + ); + + return { + hasUnsavedChanges, + lastModifiedTimestamp, + modifiedLocally, + lastCommitTime: workspaceMeta?.cachedGitLastCommitTime, + lastCommitAuthor, + lastActiveBranch, + spec, + specFormat, + name: isDesign(workspace) ? apiSpec.fileName : workspace.name, + apiSpec, + specFormatVersion, + workspace, + }; + }; + + // @TODO - Figure out if the database has a way to sort/filter items that could replace this logic. + const filterWorkspace = (workspace: WorkspaceWithMetadata) => { + const matchResults = fuzzyMatchAll( + // Use the filter string + filter, + // to match against these properties + [ + workspace.name, + workspace.workspace.scope === 'design' ? 'document' : 'collection', + workspace.lastActiveBranch || '', + workspace.specFormatVersion || '', + ], + { + splitSpace: true, + loose: true, + } + ); + + return filter ? Boolean(matchResults?.indexes) : true; + }; + + function sortWorkspaces( + workspaceWithMetaA: WorkspaceWithMetadata, + workspaceWithMetaB: WorkspaceWithMetadata + ) { + switch (sortOrder) { + case 'modified-desc': + return sortMethodMap['modified-desc']( + workspaceWithMetaA, + workspaceWithMetaB + ); + case 'name-asc': + return sortMethodMap['name-asc']( + workspaceWithMetaA.workspace, + workspaceWithMetaB.workspace + ); + case 'name-desc': + return sortMethodMap['name-desc']( + workspaceWithMetaA.workspace, + workspaceWithMetaB.workspace + ); + case 'created-asc': + return sortMethodMap['created-asc']( + workspaceWithMetaA.workspace, + workspaceWithMetaB.workspace + ); + case 'created-desc': + return sortMethodMap['created-desc']( + workspaceWithMetaA.workspace, + workspaceWithMetaB.workspace + ); + default: + throw new Error(`Invalid sort order: ${sortOrder}`); + } } - let spec: ParsedApiSpec['contents'] = null; - let specFormat: ParsedApiSpec['format'] = null; - let specFormatVersion: ParsedApiSpec['formatVersion'] = null; - - try { - const result = parseApiSpec(apiSpec.contents); - spec = result.contents; - specFormat = result.format; - specFormatVersion = result.formatVersion; - } catch (err) { - // Assume there is no spec - // TODO: Check for parse errors if it's an invalid spec - } - - // Get cached branch from WorkspaceMeta - const workspaceMeta = workspaceMetas?.find(({ parentId }) => parentId === workspace._id); - - const lastActiveBranch = workspaceMeta?.cachedGitRepositoryBranch; - - const lastCommitAuthor = workspaceMeta?.cachedGitLastAuthor; - - // WorkspaceMeta is a good proxy for last modified time - const workspaceModified = workspaceMeta?.modified || workspace.modified; - - const modifiedLocally = isDesign(workspace) - ? apiSpec.modified - : workspaceModified; - - // Span spec, workspace and sync related timestamps for card last modified label and sort order - const lastModifiedFrom = [ - workspace?.modified, - workspaceMeta?.modified, - apiSpec.modified, - workspaceMeta?.cachedGitLastCommitTime, - ]; - - const lastModifiedTimestamp = lastModifiedFrom - .filter(isNotNullOrUndefined) - .sort(descendingNumberSort)[0]; - - const hasUnsavedChanges = Boolean( - isDesign(workspace) && workspaceMeta?.cachedGitLastCommitTime && apiSpec.modified > workspaceMeta?.cachedGitLastCommitTime + // Fetch all workspace meta data in parallel + const workspacesWithMetaData = await Promise.all( + projectWorkspaces.map(getWorkspaceMetaData) ); + const workspaces = workspacesWithMetaData + .filter(filterWorkspace) + .sort(sortWorkspaces); + + // Load all projects + try { + const vcs = getVCS(); + if (vcs && isLoggedIn()) { + const teams = await vcs.teams(); + const projects = await Promise.all(teams.map(initializeProjectFromTeam)); + await database.batchModifyDocs({ upsert: projects }); + } + } catch { + console.log('Failed to load projects'); + } + + const projects = sortProjects(await models.project.all()); + return { - hasUnsavedChanges, - lastModifiedTimestamp, - modifiedLocally, - lastCommitTime: workspaceMeta?.cachedGitLastCommitTime, - lastCommitAuthor, - lastActiveBranch, - spec, - specFormat, - apiSpec, - specFormatVersion, - workspace, + workspaces, + projects, + activeProject: project, }; }; -const WrapperHome: FC = (() => { - const sortOrder = useSelector(selectDashboardSortOrder); - const activeProject = useSelector(selectActiveProject); - const isLoggedIn = useSelector(selectIsLoggedIn); +const ProjectRoute: FC = () => { + const { workspaces, activeProject, projects } = useLoaderData() as LoaderData; + const [searchParams] = useSearchParams(); const isLoading = useSelector(selectIsLoading); - const apiSpecs = useSelector(selectApiSpecs); - const workspaceMetas = useSelector(selectWorkspaceMetas); - const workspacesForActiveProject = useSelector(selectWorkspacesForActiveProject); - const vcs = useVCS({}); const dispatch = useDispatch(); + const fetcher = useFetcher(); + const { revalidate } = useRevalidator(); - const [filter, setFilter] = useState(''); + const submit = useSubmit(); + const navigate = useNavigate(); + const filter = searchParams.get('filter') || ''; + const sortOrder = + (searchParams.get('sortOrder') as DashboardSortOrder) || 'modified-desc'; - // Render each card, removing all the ones that don't match the filter - const cards = workspacesForActiveProject - .map(mapWorkspaceToWorkspaceCard({ workspaceMetas, apiSpecs })) - .filter(isNotNullOrUndefined) - .sort(orderDashboardCards(sortOrder)) - .map(card => ( - dispatch(activateWorkspace({ workspace: card.workspace }))} - filter={filter} - /> - )); - - const createRequestCollection = useCallback(() => { - dispatch(createWorkspace({ - scope: WorkspaceScopeKeys.collection, - onCreate: async (workspace: Workspace) => { - // Don't mark for sync if not logged in at the time of creation - if (isLoggedIn && vcs && isRemoteProject(activeProject)) { - await initializeLocalBackendProjectAndMarkForSync({ vcs: vcs.newInstance(), workspace }); - } + const createNewCollection = () => { + showPrompt({ + title: 'Create New Request Collection', + submitName: 'Create', + placeholder: 'My Collection', + defaultValue: 'My Collection', + selectText: true, + onComplete: async (name: string) => { + fetcher.submit( + { + name, + scope: 'collection', + }, + { + action: `project/${activeProject._id}/workspace/new`, + method: 'post', + } + ); }, - })); - }, [activeProject, dispatch, isLoggedIn, vcs]); + }); + }; - const createDesignDocument = useCallback(() => { - dispatch(createWorkspace({ scope: WorkspaceScopeKeys.design })); - }, [dispatch]); + const createNewDocument = () => { + showPrompt({ + title: 'Create New Design Document', + submitName: 'Create', + placeholder: 'my-spec.yaml', + defaultValue: 'my-spec.yaml', + selectText: true, + onComplete: async (name: string) => { + fetcher.submit( + { + name, + scope: 'design', + }, + { + action: `project/${activeProject._id}/workspace/new`, + method: 'post', + } + ); + }, + }); + }; const importFromURL = useCallback(() => { showPrompt({ @@ -197,139 +323,165 @@ const WrapperHome: FC = (() => { label: 'URL', placeholder: 'https://website.com/insomnia-import.json', onComplete: uri => { - dispatch(importUri(uri, { forceToWorkspace: ForceToWorkspace.existing })); + dispatch( + importUri(uri, { forceToWorkspace: ForceToWorkspace.existing, onComplete: revalidate }) + ); }, }); - }, [dispatch]); + }, [dispatch, revalidate]); const importFromClipboard = useCallback(() => { - dispatch(importClipBoard({ forceToWorkspace: ForceToWorkspace.existing })); - }, [dispatch]); + dispatch(importClipBoard({ forceToWorkspace: ForceToWorkspace.existing, onComplete: revalidate })); + }, [dispatch, revalidate]); const importFromFile = useCallback(() => { - dispatch(importFile({ forceToWorkspace: ForceToWorkspace.existing })); - }, [dispatch]); + dispatch(importFile({ forceToWorkspace: ForceToWorkspace.existing, onComplete: revalidate })); + }, [dispatch, revalidate]); const importFromGit = useCallback(() => { dispatch(cloneGitRepository({ createFsClient: MemClient.createClient })); }, [dispatch]); return ( - , - }, - ], - isLoading, - }} - /> - } - renderPageBody={ -
-
-
-

Dashboard

-
-
- { - setFilter(event.currentTarget.value); + + + ), + }, + ], + isLoading, + }} + /> + } + renderPageBody={ +
+
+
+

Dashboard

+
+
+ + submit({ + filter: event.target.value, + sortOrder, + }) + } + className="no-margin" + /> + +
+ { + submit({ + sortOrder, + filter, + }); }} - className="no-margin" /> - + {isRemoteProject(activeProject) && } + + + Create + + New + + + Request Collection + + + + Design Document + + Import From + + + File + + + + URL + + + + Clipboard + + + + Git Clone + +
- { - dispatch(setDashboardSortOrder(sortOrder)); - }} - /> - - - - Create - - New - - - Request Collection - - - - Design Document - - Import From - - - File - - - - URL - - - - Clipboard - - - - Git Clone - -
+ + {workspaces.map(workspace => ( + + navigate( + `/project/${activeProject._id}/workspace/${ + workspace.workspace._id + }/${ + workspace.workspace.scope === 'design' + ? ACTIVITY_SPEC + : ACTIVITY_DEBUG + }` + ) + } + filter={filter} + /> + ))} + + {filter && workspaces.length === 0 && ( +

+ No documents found for {filter} +

+ )} + {!filter && !workspaces.length && ( + + )} +
+ - {cards} - {filter && cards.length === 0 && ( -

- No documents found for {filter} -

- )} - {!filter && !cards.length && ( - - )}
- -
- } - /> + } + /> + ); -}); +}; -export default WrapperHome; +export default ProjectRoute; diff --git a/packages/insomnia/src/ui/routes/remote-collections.tsx b/packages/insomnia/src/ui/routes/remote-collections.tsx new file mode 100644 index 0000000000..8a0de60645 --- /dev/null +++ b/packages/insomnia/src/ui/routes/remote-collections.tsx @@ -0,0 +1,74 @@ +import { invariant } from '@remix-run/router'; +import { ActionFunction, LoaderFunction } from 'react-router-dom'; + +import { database } from '../../common/database'; +import * as models from '../../models'; +import { RemoteProject } from '../../models/project'; +import { BackendProject } from '../../sync/types'; +import { pullBackendProject } from '../../sync/vcs/pull-backend-project'; +import { getVCS } from '../../sync/vcs/vcs'; + +export const pullRemoteCollectionAction: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + + const backendProjectId = formData.get('backendProjectId'); + invariant(typeof backendProjectId === 'string', 'Collection Id is required'); + const remoteId = formData.get('remoteId'); + invariant(typeof remoteId === 'string', 'Remote Id is required'); + + const vcs = getVCS(); + invariant(vcs, 'VCS is not defined'); + + const remoteBackendProjects = await vcs.remoteBackendProjects(remoteId); + const backendProject = remoteBackendProjects.find(p => p.id === backendProjectId); + + invariant(backendProject, 'Backend project not found'); + + const remoteProjects = await database.find(models.project.type, { + // @ts-expect-error -- Improve database query typing + $not: { + remoteId: null, + }, + }); + + // Clone old VCS so we don't mess anything up while working on other backend projects + const newVCS = vcs.newInstance(); + // Remove all backend projects for workspace first + await newVCS.removeBackendProjectsForRoot(backendProject.rootDocumentId); + + await pullBackendProject({ vcs: newVCS, backendProject, remoteProjects }); +}; + +export interface RemoteCollectionsLoaderData { + remoteBackendProjects: BackendProject[]; +} + +export const remoteCollectionsLoader: LoaderFunction = async ({ params }) => { + const { projectId } = params; + invariant(typeof projectId === 'string', 'Project Id is required'); + + const project = await models.project.getById(projectId); + + invariant(project, 'Project not found'); + + const vcs = getVCS(); + invariant(vcs, 'VCS is not defined'); + + const remoteId = project.remoteId; + invariant(remoteId, 'Project is not a remote project'); + + const localBackendProjects = await vcs.localBackendProjects(); + + const remoteBackendProjects = (await vcs.remoteBackendProjects(remoteId)).filter(({ id, rootDocumentId }) => { + const localBackendProjectExists = localBackendProjects.find(p => p.id === id); + const workspaceExists = Boolean(models.workspace.getById(rootDocumentId)); + // Mark as missing if: + // - the backend project doesn't yet exists locally + // - the backend project exists locally but somehow the workspace doesn't anymore + return !(workspaceExists && localBackendProjectExists); + }); + + return { + remoteBackendProjects, + }; +}; diff --git a/packages/insomnia/src/ui/routes/root.tsx b/packages/insomnia/src/ui/routes/root.tsx index 6aa032748a..4fb4b8acef 100644 --- a/packages/insomnia/src/ui/routes/root.tsx +++ b/packages/insomnia/src/ui/routes/root.tsx @@ -1,94 +1,31 @@ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { Outlet, useNavigate } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; -import { - ACTIVITY_HOME, -} from '../../common/constants'; import { database as db } from '../../common/database'; import * as models from '../../models'; import { ErrorBoundary } from '../components/error-boundary'; -import { AddKeyCombinationModal } from '../components/modals/add-key-combination-modal'; -import { AlertModal } from '../components/modals/alert-modal'; -import { AnalyticsModal } from '../components/modals/analytics-modal'; -import { AskModal } from '../components/modals/ask-modal'; -import { CodePromptModal } from '../components/modals/code-prompt-modal'; -import { CookieModifyModal } from '../components/modals/cookie-modify-modal'; -import { CookiesModal } from '../components/modals/cookies-modal'; -import { EnvironmentEditModal } from '../components/modals/environment-edit-modal'; -import { ErrorModal } from '../components/modals/error-modal'; -import { ExportRequestsModal } from '../components/modals/export-requests-modal'; -import { FilterHelpModal } from '../components/modals/filter-help-modal'; -import { GenerateCodeModal } from '../components/modals/generate-code-modal'; -import { GenerateConfigModal } from '../components/modals/generate-config-modal'; -import { GitBranchesModal } from '../components/modals/git-branches-modal'; -import { GitLogModal } from '../components/modals/git-log-modal'; -import { GitRepositorySettingsModal } from '../components/modals/git-repository-settings-modal'; -import { GitStagingModal } from '../components/modals/git-staging-modal'; -import { registerModal } from '../components/modals/index'; -import { LoginModal } from '../components/modals/login-modal'; -import { NunjucksModal } from '../components/modals/nunjucks-modal'; -import ProjectSettingsModal from '../components/modals/project-settings-modal'; -import { PromptModal } from '../components/modals/prompt-modal'; -import { ProtoFilesModal } from '../components/modals/proto-files-modal'; -import { RequestGroupSettingsModal } from '../components/modals/request-group-settings-modal'; -import { RequestRenderErrorModal } from '../components/modals/request-render-error-modal'; -import { RequestSettingsModal } from '../components/modals/request-settings-modal'; -import { RequestSwitcherModal } from '../components/modals/request-switcher-modal'; -import { ResponseDebugModal } from '../components/modals/response-debug-modal'; -import { SelectModal } from '../components/modals/select-modal'; -import { SettingsModal } from '../components/modals/settings-modal'; -import { SyncBranchesModal } from '../components/modals/sync-branches-modal'; -import { SyncDeleteModal } from '../components/modals/sync-delete-modal'; -import { SyncHistoryModal } from '../components/modals/sync-history-modal'; -import { SyncMergeModal } from '../components/modals/sync-merge-modal'; -import { SyncStagingModal } from '../components/modals/sync-staging-modal'; -import { WorkspaceDuplicateModal } from '../components/modals/workspace-duplicate-modal'; -import { WorkspaceEnvironmentsEditModal } from '../components/modals/workspace-environments-edit-modal'; -import { WorkspaceSettingsModal } from '../components/modals/workspace-settings-modal'; -import { WrapperModal } from '../components/modals/wrapper-modal'; import { Toast } from '../components/toast'; import { AppHooks } from '../containers/app-hooks'; import withDragDropContext from '../context/app/drag-drop-context'; -import { GrpcDispatchModalWrapper, GrpcProvider } from '../context/grpc'; +import { GrpcProvider } from '../context/grpc'; import { NunjucksEnabledProvider } from '../context/nunjucks/nunjucks-enabled-context'; -import { useGitVCS } from '../hooks/use-git-vcs'; -import { useVCS } from '../hooks/use-vcs'; import { - selectActiveActivity, selectActiveApiSpec, selectActiveCookieJar, - selectActiveEnvironment, - selectActiveGitRepository, - selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceMeta, selectEnvironments, selectIsFinishedBooting, selectIsLoggedIn, } from '../redux/selectors'; - -const ActivityRouter = () => { - const selectedActivity = useSelector(selectActiveActivity); - const activeWorkspace = useSelector(selectActiveWorkspace); - // If there is no active workspace, we want to navigate to home no matter what the previous activity was - const activity = activeWorkspace ? selectedActivity : ACTIVITY_HOME; - const navigate = useNavigate(); - - React.useEffect(() => { - if (activity) { - navigate(activity); - } - }, [activity, navigate]); - - return null; -}; +import Modals from './modals'; interface State { isMigratingChildren: boolean; } -const App = () => { +const Root = () => { const [state, setState] = useState({ isMigratingChildren: false, }); @@ -100,19 +37,6 @@ const App = () => { const environments = useSelector(selectEnvironments); const isLoggedIn = useSelector(selectIsLoggedIn); const isFinishedBooting = useSelector(selectIsFinishedBooting); - const activeGitRepository = useSelector(selectActiveGitRepository); - const activeEnvironment = useSelector(selectActiveEnvironment); - const activeProject = useSelector(selectActiveProject); - - const vcs = useVCS({ - workspaceId: activeWorkspace?._id, - }); - - const gitVCS = useGitVCS({ - workspaceId: activeWorkspace?._id, - projectId: activeProject?._id, - gitRepository: activeGitRepository, - }); // Ensure Children: Make sure cookies, env, and meta models are created under this workspace useEffect(() => { @@ -158,99 +82,8 @@ const App = () => {
-
- - - registerModal(instance, 'AlertModal')} /> - registerModal(instance, 'ErrorModal')} /> - registerModal(instance, 'PromptModal')} /> - registerModal(instance, 'WrapperModal')} /> - - registerModal(instance, 'AskModal')} /> - registerModal(instance, 'SelectModal')} /> - registerModal(instance, 'FilterHelpModal')} /> - registerModal(instance, 'RequestRenderErrorModal')} /> - registerModal(instance, 'GenerateConfigModal')} /> - registerModal(instance, 'ProjectSettingsModal')} /> - registerModal(instance, 'WorkspaceDuplicateModal')} /> - registerModal(instance, 'CodePromptModal')} /> - registerModal(instance, 'RequestSettingsModal')} /> - registerModal(instance, 'RequestGroupSettingsModal')} /> - - {activeWorkspace ? <> - {/* TODO: Figure out why cookieJar is sometimes null */} - {activeCookieJar ? <> - registerModal(instance, 'CookiesModal')} - /> - registerModal(instance, 'CookieModifyModal')} /> - : null} - - registerModal(instance, 'NunjucksModal')} - workspace={activeWorkspace} - /> - - {activeApiSpec ? registerModal(instance, 'WorkspaceSettingsModal')} - /> : null} - : null} - - registerModal(instance, 'GenerateCodeModal')} - environmentId={activeEnvironment ? activeEnvironment._id : 'n/a'} - /> - - registerModal(instance, 'SettingsModal')} /> - registerModal(instance, 'ResponseDebugModal')} /> - - registerModal(instance, 'RequestSwitcherModal')} /> - - registerModal(instance, 'EnvironmentEditModal')} /> - - registerModal(instance, 'GitRepositorySettingsModal')} /> - - {activeWorkspace && gitVCS ? ( - - registerModal(instance, 'GitStagingModal')} workspace={activeWorkspace} vcs={gitVCS} gitRepository={activeGitRepository} /> - registerModal(instance, 'GitLogModal')} vcs={gitVCS} /> - {activeGitRepository !== null && ( - registerModal(instance, 'GitBranchesModal')} - vcs={gitVCS} - /> - )} - - ) : null} - - {activeWorkspace && vcs ? ( - - registerModal(instance, 'SyncStagingModal')} vcs={vcs} /> - registerModal(instance, 'SyncMergeModal')} /> - registerModal(instance, 'SyncBranchesModal')} vcs={vcs} /> - registerModal(instance, 'SyncDeleteModal')} vcs={vcs} /> - registerModal(instance, 'SyncHistoryModal')} vcs={vcs} /> - - ) : null} - - registerModal(instance, 'WorkspaceEnvironmentsEditModal')} /> - - registerModal(instance, 'AddKeyCombinationModal')} /> - registerModal(instance, 'ExportRequestsModal')} /> - - - {dispatch => ( - registerModal(instance, 'ProtoFilesModal')} - grpcDispatch={dispatch} - /> - )} - - -
- + -
@@ -262,4 +95,4 @@ const App = () => { ); }; -export default withDragDropContext(App); +export default withDragDropContext(Root);