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
This commit is contained in:
James Gatz authored and GitHub committed 2022-10-28 12:04:09 +00:00
1 parent 1a09035743
commit 40d294e8ae
19 files changed
+1699 -935

No files matched your search

+11
View File
@@ -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;
};
@@ -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'] }) => (
<StyledTooltip message={message}>
const TooltipIcon = ({
message,
icon,
}: {
message: string;
icon: SvgIconProps['icon'];
}) => (
<Tooltip message={message}>
<StyledSvgIcon icon={icon} />
</StyledTooltip>
</Tooltip>
);
const spinner = <i className="fa fa-spin fa-refresh" />;
const home = <TooltipIcon message={`${strings.defaultProject.singular} ${strings.project.singular} (Always ${strings.localProject.singular})`} icon="home" />;
const remoteProject = <TooltipIcon message={`${strings.remoteProject.singular} ${strings.project.singular}`} icon="globe" />;
const localProject = <TooltipIcon message={`${strings.localProject.singular} ${strings.project.singular}`} icon="laptop" />;
const Spinner = () => <i className="fa fa-spin fa-refresh" />;
const HomeIcon = () => (
<TooltipIcon
message={`${strings.defaultProject.singular} ${strings.project.singular} (Always ${strings.localProject.singular})`}
icon="home"
/>
);
const RemoteProjectIcon = () => (
<TooltipIcon
message={`${strings.remoteProject.singular} ${strings.project.singular}`}
icon="globe"
/>
);
const LocalProjectIcon = () => (
<TooltipIcon
message={`${strings.localProject.singular} ${strings.project.singular}`}
icon="laptop"
/>
);
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<Props> = ({ 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 (
<DropdownItem
key={_id}
onClick={() => setActive(_id)}
>
<Item>
{isDefault ? home : isRemote ? remoteProject : localProject}
{name}
{isActive && <Checkmark icon="checkmark" />}
</Item>
</DropdownItem>
);
};
ProjectDropdownItem.displayName = DropdownItem.name; // This is required because the Dropdown component will otherwise silently disregard this component.
export const ProjectDropdown: FC<Props> = ({ 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) => (
<ProjectDropdownItem
key={project._id}
isActive={project._id === activeProject._id}
setActive={setActive}
project={project}
/>
), [setActive, activeProject._id]);
const [defaultProjects, userProjects] = partition(isDefaultProject, projects);
return (
<Dropdown onOpen={refresh}>
<DropdownButton className="row" title={activeProject.name}>
{activeProject.name}
<i className="fa fa-caret-down space-left" />
</DropdownButton>
{defaultProjects.map(renderProject)}
<DropdownDivider>All {strings.project.plural.toLowerCase()}{' '}{loading && spinner}</DropdownDivider>
{userProjects.map(renderProject)}
{projectHasSettings(activeProject) && <>
<DropdownDivider />
<DropdownItem onClick={showSettings}>
<Item><StyledSvgIcon icon="gear" />{strings.project.singular} Settings</Item>
<Fragment>
<Dropdown onOpen={revalidate}>
<DropdownButton className="row" title={activeProject.name}>
{activeProject.name}
<i className="fa fa-caret-down space-left" />
</DropdownButton>
<DropdownItem
key={defaultProject._id}
onClick={() => navigate(`/project/${defaultProject._id}`)}
>
<Item>
<HomeIcon />
{defaultProject.name}
{defaultProject._id === activeProject._id && (
<Checkmark icon="checkmark" />
)}
</Item>
</DropdownItem>
</>}
<DropdownDivider>
All {strings.project.plural.toLowerCase()}{' '}
{state === 'loading' && <Spinner />}
</DropdownDivider>
{userProjects.map(project => {
return (
<DropdownItem
key={project._id}
onClick={() => navigate(`/project/${project._id}`)}
>
<Item>
{isRemoteProject(project) ? (
<RemoteProjectIcon />
) : (
<LocalProjectIcon />
)}
{project.name}
{project._id === activeProject._id && (
<Checkmark icon="checkmark" />
)}
</Item>
</DropdownItem>
);
})}
{!isDefaultProject(activeProject) && (
<>
<DropdownDivider />
<DropdownItem onClick={() => setIsSettingsModalOpen(true)}>
<Item>
<StyledSvgIcon icon="gear" />
{strings.project.singular} Settings
</Item>
</DropdownItem>
</>
)}
<DropdownDivider />
<DropdownItem onClick={createNew}>
<Item><StyledSvgIcon icon="plus" /> Create new {strings.project.singular.toLowerCase()}</Item>
</DropdownItem>
</Dropdown>
<DropdownDivider />
<DropdownItem
onClick={() => {
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',
}
),
});
}}
>
<Item>
<StyledSvgIcon icon="plus" /> Create new{' '}
{strings.project.singular.toLowerCase()}
</Item>
</DropdownItem>
</Dropdown>
{isSettingsModalOpen && (
<ProjectSettingsModal
onHide={() => setIsSettingsModalOpen(false)}
project={activeProject}
/>
)}
</Fragment>
);
};
@@ -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<Props> = ({ 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 (
<DropdownItem
key={backendProjectId}
stayOpenAfterClick
onClick={() =>
submit(
{
remoteId,
backendProjectId,
},
{
action: `project/${projectId}/remote-collections/pull`,
method: 'post',
}
)
}
>
{state === 'submitting' ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-download" />
)}
<span>
Pull <strong>{name}</strong>
</span>
</DropdownItem>
);
};
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<Props> = ({ project }) => {
const { load, data, state } = useFetcher<RemoteCollectionsLoaderData>();
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 (
<Tooltip message="Please log in to access your remote collections" position="bottom">
<Tooltip
message="Please log in to access your remote collections"
position="bottom"
>
<PullButton disabled>
Pull <i className="fa fa-caret-down pad-left-sm" />
</PullButton>
@@ -59,36 +89,29 @@ export const RemoteWorkspacesDropdown: FC<Props> = ({ vcs }) => {
}
return (
<Dropdown onOpen={refresh}>
<Dropdown onOpen={() => load(`/project/${project._id}/remote-collections`)}>
<DropdownButton buttonClass={PullButton}>
Pull <i className="fa fa-caret-down pad-left-sm" />
</DropdownButton>
<DropdownDivider>
Remote {strings.collection.plural}
<HelpTooltip>
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.
</HelpTooltip>{' '}
{loading && <i className="fa fa-spin fa-refresh" />}
{state === 'loading' && <i className="fa fa-spin fa-refresh" />}
</DropdownDivider>
{missingBackendProjects.length === 0 && (
{remoteBackendProjects.length === 0 && (
<DropdownItem disabled>Nothing to pull</DropdownItem>
)}
{missingBackendProjects.map(p => (
<DropdownItem
key={p.id}
stayOpenAfterClick
onClick={() => pull(p)}
>
{pullingBackendProjects[p.id] ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-download" />
)}
<span>
Pull <strong>{p.name}</strong>
</span>
</DropdownItem>
{remoteBackendProjects.map(backendProject => (
<RemoteWorkspaceDropdownItem
projectId={project._id}
key={backendProject.id}
name={backendProject.name}
backendProjectId={backendProject.id}
remoteId={project.remoteId}
/>
))}
</Dropdown>
);
@@ -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 = <i className="fa fa-refresh fa-spin" />;
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<DocumentAction[]>([]);
const { startLoading, stopLoading, isLoading } = useLoadingRecord();
@@ -116,21 +77,83 @@ const useDocumentActionPlugins = ({ workspace, apiSpec, project }: Props) => {
};
export const WorkspaceCardDropdown: FC<Props> = 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 (
<Dropdown beside onOpen={refresh}>
<DropdownButton><SvgIcon icon="ellipsis" /></DropdownButton>
<Fragment>
<Dropdown beside onOpen={refresh}>
<DropdownButton>
<SvgIcon icon="ellipsis" />
</DropdownButton>
<DropdownItem onClick={handleDuplicate}>Duplicate</DropdownItem>
<DropdownItem onClick={handleRename}>Rename</DropdownItem>
<DropdownItem onClick={() => setIsDuplicateModalOpen(true)}>Duplicate</DropdownItem>
<DropdownItem
onClick={() => {
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
</DropdownItem>
{renderPluginDropdownItems()}
{renderPluginDropdownItems()}
<DropdownDivider />
<DropdownDivider />
<DropdownItem className="danger" onClick={handleDelete}>Delete</DropdownItem>
</Dropdown>
<DropdownItem
className="danger"
onClick={() => {
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
</DropdownItem>
</Dropdown>
{isDuplicateModalOpen && (
<WorkspaceDuplicateModal
onHide={() => setIsDuplicateModalOpen(false)}
workspace={workspace}
projects={projects}
/>
)}
</Fragment>
);
};
@@ -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<ProjectSettingsModalHandle, ModalProps>((_, ref) => {
export const ProjectSettingsModal: FC<ProjectSettingsModalProps> = ({ project, onHide }) => {
const modalRef = useRef<ModalHandle>(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 (
<Modal ref={modalRef}>
<Modal onHide={onHide} ref={modalRef}>
<ModalHeader key={`header::${project._id}`}>
{strings.project.singular} Settings{' '}
<div className="txt-sm selectable faint monospace">{project._id}</div>
@@ -44,7 +36,11 @@ export const ProjectSettingsModal = forwardRef<ProjectSettingsModalHandle, Modal
{isRemote && (
<>
<HelpTooltip className="space-left">
To rename a {strings.remoteProject.singular.toLowerCase()} {strings.project.singular.toLowerCase()} please visit <a href="https://app.insomnia.rest/app/teams">the insomnia website.</a>
To rename a {strings.remoteProject.singular.toLowerCase()}{' '}
{strings.project.singular.toLowerCase()} please visit{' '}
<a href="https://app.insomnia.rest/app/teams">
the insomnia website.
</a>
</HelpTooltip>
<input disabled readOnly defaultValue={project.name} />
</>
@@ -54,7 +50,17 @@ export const ProjectSettingsModal = forwardRef<ProjectSettingsModalHandle, Modal
type="text"
placeholder={`My ${strings.project.singular}`}
defaultValue={project.name}
onChange={event => models.project.update(project, { name: event.target.value })}
onChange={e => {
submit(
{
name: e.currentTarget.value,
},
{
action: `/project/${project._id}/rename`,
method: 'post',
}
);
}}
/>
)}
</label>
@@ -62,10 +68,12 @@ export const ProjectSettingsModal = forwardRef<ProjectSettingsModalHandle, Modal
<h2>Actions</h2>
<div className="form-control form-control--padded">
<PromptButton
onClick={() => {
dispatch(removeProject(project));
modalRef.current?.hide();
}}
onClick={() =>
submit(
{},
{ method: 'post', action: `/project/${project._id}/delete` }
)
}
className="width-auto btn btn--clicky inline-block"
>
<i className="fa fa-trash-o" /> Delete
@@ -74,7 +82,8 @@ export const ProjectSettingsModal = forwardRef<ProjectSettingsModalHandle, Modal
</ModalBody>
</Modal>
);
});
};
ProjectSettingsModal.displayName = 'ProjectSettingsModal';
export default ProjectSettingsModal;
@@ -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<WorkspaceDuplicateModalHandle, ModalProps>((_, ref) => {
export const WorkspaceDuplicateModal: FC<WorkspaceDuplicateModalProps> = ({ workspace, projects, ...modalProps }) => {
const { Form } = useFetcher();
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<ErrorModalOptions>({});
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 (
<Modal ref={modalRef}>
<ModalHeader>{`Duplicate ${state.workspace && getWorkspaceLabel(state.workspace).singular}`}</ModalHeader>
<ModalBody className="wide">
<form
onSubmit={async e => {
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"
>
<div className="form-control form-control--wide form-control--outlined">
<label>
New Name
<input name="name" defaultValue={state.workspace ? isDesign(state.workspace) ? (state.apiSpec?.fileName || '') : state.workspace.name : ''} />
</label>
</div>
<div className="form-control form-control--outlined">
<label>
{strings.project.singular} to duplicate into
<select defaultValue={state.workspace?.parentId} name="projectId">
{projects.map(project => (
<option key={project._id} value={project._id}>
{project.name} ({isDefaultProject(project) ? strings.defaultProject.singular : isLocalProject(project) ? strings.localProject.singular : strings.remoteProject.singular})
</option>
))}
</select>
</label>
</div>
</form>
</ModalBody>
<ModalFooter>
<button type="submit" form="workspace-duplicate-form" className="btn">
Duplicate
</button>
</ModalFooter>
</Modal>
<OverlayContainer onClick={e => e.stopPropagation()}>
<Modal {...modalProps} ref={modalRef}>
<ModalHeader>{`Duplicate ${workspace && getWorkspaceLabel(workspace).singular}`}</ModalHeader>
<ModalBody className="wide">
<Form
onSubmit={() => {
modalRef.current?.hide();
}}
method='post'
action={`/project/${workspace.parentId}/workspace/${workspace._id}/duplicate`}
id="workspace-duplicate-form"
className="wide pad"
>
<div className="form-control form-control--wide form-control--outlined">
<label>
New Name
<input name="name" defaultValue={workspace.name} />
</label>
</div>
<input name="workspaceId" value={workspace._id} readOnly className="hidden" />
<div className="form-control form-control--outlined">
<label>
{strings.project.singular} to duplicate into
<select defaultValue={workspace.parentId} name="projectId">
{projects.map(project => (
<option key={project._id} value={project._id}>
{project.name} ({isDefaultProject(project) ? strings.defaultProject.singular : isLocalProject(project) ? strings.localProject.singular : strings.remoteProject.singular})
</option>
))}
</select>
</label>
</div>
</Form>
</ModalBody>
<ModalFooter>
<button type="submit" form="workspace-duplicate-form" className="btn">
Duplicate
</button>
</ModalFooter>
</Modal>
</OverlayContainer>
);
});
WorkspaceDuplicateModal.displayName = 'WorkspaceDuplicateModal';
};
@@ -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<WorkspaceSettingsModalHandle, M
showDescription: false,
defaultPreviewMode: false,
});
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
const workspace = useSelector(selectActiveWorkspace);
const projects = useSelector(selectProjects);
const apiSpec = useSelector(selectActiveApiSpec);
const activeWorkspaceName = useSelector(selectActiveWorkspaceName);
const clientCertificates = useSelector(selectActiveWorkspaceClientCertificates);
@@ -278,13 +279,12 @@ export const WorkspaceSettingsModal = forwardRef<WorkspaceSettingsModalHandle, M
<i className="fa fa-trash-o" /> Delete
</PromptButton>
<button
onClick={() => {
showModal(WorkspaceDuplicateModal, { workspace, apiSpec, onDone: modalRef.current?.hide });
}}
onClick={() => setIsDuplicateModalOpen(true)}
className="width-auto btn btn--clicky inline-block space-left"
>
<i className="fa fa-copy" /> Duplicate
</button>
{isDuplicateModalOpen && <WorkspaceDuplicateModal projects={projects} workspace={workspace} onHide={() => setIsDuplicateModalOpen(false)} />}
<PromptButton
onClick={_handleClearAllResponses}
className="width-auto btn btn--clicky inline-block space-left"
@@ -1,6 +1,7 @@
import { importers } from 'insomnia-importers';
import React, { FC, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRevalidator } from 'react-router-dom';
import { getProductName } from '../../../common/constants';
import { docsImportExport } from '../../../common/documentation';
@@ -31,7 +32,7 @@ export const ImportExport: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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);
@@ -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<WorkspaceCardProps> = ({
specFormat,
specFormatVersion,
hasUnsavedChanges,
projects,
onSelect,
}) => {
let branch = lastActiveBranch;
@@ -81,10 +85,7 @@ export const WorkspaceCard: FC<WorkspaceCardProps> = ({
if (modifiedLocally) {
log = (
<Fragment>
<TimeFromNow
className="text-danger"
timestamp={modifiedLocally}
/>{' '}
<TimeFromNow className="text-danger" timestamp={modifiedLocally} />{' '}
(unsaved)
</Fragment>
);
@@ -98,13 +99,6 @@ export const WorkspaceCard: FC<WorkspaceCardProps> = ({
</Fragment>
);
}
const docMenu = (
<WorkspaceCardDropdown
apiSpec={apiSpec}
workspace={workspace}
project={activeProject}
/>
);
const version = getVersionDisplayment(spec?.info?.version);
let label: string = strings.collection.singular;
@@ -129,10 +123,14 @@ export const WorkspaceCard: FC<WorkspaceCardProps> = ({
}
// 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<WorkspaceCardProps> = ({
return (
<Card
docBranch={branch ? <Highlight search={filter} text={branch} /> : undefined}
docBranch={
branch ? <Highlight search={filter} text={branch} /> : undefined
}
docTitle={title ? <Highlight search={filter} text={title} /> : undefined}
docVersion={version ? <Highlight search={filter} text={version} /> : undefined}
tagLabel={label ? (
<>
<span className="margin-right-xs">{labelIcon}</span>
<Highlight search={filter} text={label} />
</>
) : undefined}
docVersion={
version ? <Highlight search={filter} text={version} /> : undefined
}
tagLabel={
label ? (
<>
<span className="margin-right-xs">{labelIcon}</span>
<Highlight search={filter} text={label} />
</>
) : undefined
}
docLog={log}
docMenu={docMenu}
docMenu={
<WorkspaceCardDropdown
apiSpec={apiSpec}
workspace={workspace}
project={activeProject}
projects={projects}
/>
}
docFormat={format}
onClick={() => onSelect(workspace._id, defaultActivity)}
/>
+3 -4
View File
@@ -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,
-125
View File
@@ -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<string, boolean>;
}
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<State, Action> = (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,
};
};
+250 -35
View File
@@ -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: <Root />,
errorElement: <div>Error</div>,
errorElement: <ErrorRoute />,
children: [
{
path: ACTIVITY_HOME,
element: (
<Suspense fallback={<AppLoadingIndicator />}>
<Project />
</Suspense>
),
},
{
path: ACTIVITY_DEBUG,
element: (
<Suspense fallback={<AppLoadingIndicator />}>
<Debug />
</Suspense>
),
},
{
path: ACTIVITY_SPEC,
element: (
<Suspense fallback={<AppLoadingIndicator />}>
<Design />
</Suspense>
),
},
{
path: ACTIVITY_UNIT_TEST,
element: (
<Suspense fallback={<AppLoadingIndicator />}>
<UnitTest />
</Suspense>
),
path: 'project',
children: [
{
path: ':projectId',
id: '/project/:projectId',
loader: async (...args) =>
(await import('./routes/project')).loader(...args),
element: (
<Suspense fallback={<AppLoadingIndicator />}>
<Project />
</Suspense>
),
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: (
<Suspense fallback={<AppLoadingIndicator />}>
<Debug />
</Suspense>
),
},
{
path: `${ACTIVITY_SPEC}`,
element: (
<Suspense fallback={<AppLoadingIndicator />}>
<Design />
</Suspense>
),
},
{
path: `${ACTIVITY_UNIT_TEST}`,
element: (
<Suspense fallback={<AppLoadingIndicator />}>
<UnitTest />
</Suspense>
),
},
{
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) {
@@ -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?.();
};
+213
View File
@@ -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,
});
};
+64
View File
@@ -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 = () => <i className="fa fa-spin fa-refresh" />;
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 (
<Container>
<h1>Application Error</h1>
<p>
Failed to render. Please send the following error to{' '}
<Mailto
email="support@insomnia.rest"
subject="Error Report"
body={errorMessage}
/>
.
</p>
<h2>Message:</h2>
<pre>{errorMessage}</pre>
<Button onClick={() => navigate(`/project/${DEFAULT_PROJECT_ID}`)}>
Try to reload the app{' '}
<span>{navigation.state === 'loading' ? <Spinner /> : null}</span>
</Button>
</Container>
);
};
+239
View File
@@ -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 (
<div key="modals" className="modals">
<ErrorBoundary showAlert>
<AnalyticsModal />
<AlertModal ref={instance => registerModal(instance, 'AlertModal')} />
<ErrorModal ref={instance => registerModal(instance, 'ErrorModal')} />
<PromptModal
ref={instance => registerModal(instance, 'PromptModal')}
/>
<WrapperModal
ref={instance => registerModal(instance, 'WrapperModal')}
/>
<LoginModal ref={registerModal} />
<AskModal ref={instance => registerModal(instance, 'AskModal')} />
<SelectModal
ref={instance => registerModal(instance, 'SelectModal')}
/>
<FilterHelpModal
ref={instance => registerModal(instance, 'FilterHelpModal')}
/>
<RequestRenderErrorModal
ref={instance => registerModal(instance, 'RequestRenderErrorModal')}
/>
<GenerateConfigModal
ref={instance => registerModal(instance, 'GenerateConfigModal')}
/>
<CodePromptModal
ref={instance => registerModal(instance, 'CodePromptModal')}
/>
<RequestSettingsModal
ref={instance => registerModal(instance, 'RequestSettingsModal')}
/>
<RequestGroupSettingsModal
ref={instance =>
registerModal(instance, 'RequestGroupSettingsModal')
}
/>
{activeWorkspace ? (
<>
{/* TODO: Figure out why cookieJar is sometimes null */}
{activeCookieJar ? (
<>
<CookiesModal
ref={instance => registerModal(instance, 'CookiesModal')}
/>
<CookieModifyModal
ref={instance =>
registerModal(instance, 'CookieModifyModal')
}
/>
</>
) : null}
<NunjucksModal
ref={instance => registerModal(instance, 'NunjucksModal')}
workspace={activeWorkspace}
/>
{activeApiSpec ? (
<WorkspaceSettingsModal
ref={instance =>
registerModal(instance, 'WorkspaceSettingsModal')
}
/>
) : null}
</>
) : null}
<GenerateCodeModal
ref={instance => registerModal(instance, 'GenerateCodeModal')}
environmentId={activeEnvironment ? activeEnvironment._id : 'n/a'}
/>
<SettingsModal
ref={instance => registerModal(instance, 'SettingsModal')}
/>
<ResponseDebugModal
ref={instance => registerModal(instance, 'ResponseDebugModal')}
/>
<RequestSwitcherModal
ref={instance => registerModal(instance, 'RequestSwitcherModal')}
/>
<EnvironmentEditModal
ref={instance => registerModal(instance, 'EnvironmentEditModal')}
/>
<GitRepositorySettingsModal
ref={instance =>
registerModal(instance, 'GitRepositorySettingsModal')
}
/>
{activeWorkspace && gitVCS ? (
<Fragment>
<GitStagingModal
ref={instance => registerModal(instance, 'GitStagingModal')}
workspace={activeWorkspace}
vcs={gitVCS}
gitRepository={activeGitRepository}
/>
<GitLogModal
ref={instance => registerModal(instance, 'GitLogModal')}
vcs={gitVCS}
/>
{activeGitRepository !== null && (
<GitBranchesModal
ref={instance => registerModal(instance, 'GitBranchesModal')}
vcs={gitVCS}
/>
)}
</Fragment>
) : null}
{activeWorkspace && vcs ? (
<Fragment>
<SyncStagingModal
ref={instance => registerModal(instance, 'SyncStagingModal')}
vcs={vcs}
/>
<SyncMergeModal
ref={instance => registerModal(instance, 'SyncMergeModal')}
/>
<SyncBranchesModal
ref={instance => registerModal(instance, 'SyncBranchesModal')}
vcs={vcs}
/>
<SyncDeleteModal
ref={instance => registerModal(instance, 'SyncDeleteModal')}
vcs={vcs}
/>
<SyncHistoryModal
ref={instance => registerModal(instance, 'SyncHistoryModal')}
vcs={vcs}
/>
</Fragment>
) : null}
<WorkspaceEnvironmentsEditModal
ref={instance =>
registerModal(instance, 'WorkspaceEnvironmentsEditModal')
}
/>
<AddKeyCombinationModal
ref={instance => registerModal(instance, 'AddKeyCombinationModal')}
/>
<ExportRequestsModal
ref={instance => registerModal(instance, 'ExportRequestsModal')}
/>
<GrpcDispatchModalWrapper>
{dispatch => (
<ProtoFilesModal
ref={instance => registerModal(instance, 'ProtoFilesModal')}
grpcDispatch={dispatch}
/>
)}
</GrpcDispatchModalWrapper>
</ErrorBoundary>
</div>
);
};
export default Modals;
+401 -249
View File
@@ -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<WorkspaceCardProps, 'workspace' | 'lastModifiedTimestamp'>, cardB: Pick<WorkspaceCardProps, 'workspace' | 'lastModifiedTimestamp'>) => {
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<string, any> | 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<LoaderData> => {
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 => (
<WorkspaceCard
{...card}
key={card.apiSpec._id}
activeProject={activeProject}
onSelect={() => 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 (
<PageLayout
renderPageHeader={
<AppHeader
breadcrumbProps={{
crumbs: [
{
id: 'project',
node: <ProjectDropdown vcs={vcs || undefined} />,
},
],
isLoading,
}}
/>
}
renderPageBody={
<div className="document-listing theme--pane layout-body">
<div className="document-listing__body pad-bottom">
<div className="row-spaced margin-top margin-bottom-sm">
<h2 className="no-margin">Dashboard</h2>
<div className="row row--right pad-left wide">
<div
className="form-control form-control--outlined no-margin"
style={{
maxWidth: '400px',
}}
>
<input
autoFocus
type="text"
placeholder="Filter..."
onChange={event => {
setFilter(event.currentTarget.value);
<Fragment>
<PageLayout
renderPageHeader={
<AppHeader
breadcrumbProps={{
crumbs: [
{
id: 'project',
node: (
<ProjectDropdown
activeProject={activeProject}
projects={projects}
/>
),
},
],
isLoading,
}}
/>
}
renderPageBody={
<div className="document-listing theme--pane layout-body">
<div className="document-listing__body pad-bottom">
<div className="row-spaced margin-top margin-bottom-sm">
<h2 className="no-margin">Dashboard</h2>
<div className="row row--right pad-left wide">
<div
className="form-control form-control--outlined no-margin"
style={{
maxWidth: '400px',
}}
>
<input
autoFocus
type="text"
placeholder="Filter..."
onChange={event =>
submit({
filter: event.target.value,
sortOrder,
})
}
className="no-margin"
/>
<span className="fa fa-search filter-icon" />
</div>
<DashboardSortDropdown
value={sortOrder}
onSelect={sortOrder => {
submit({
sortOrder,
filter,
});
}}
className="no-margin"
/>
<span className="fa fa-search filter-icon" />
{isRemoteProject(activeProject) && <RemoteWorkspacesDropdown project={activeProject} />}
<Dropdown>
<DropdownButton buttonClass={CreateButton}>
Create <i className="fa fa-caret-down pad-left-sm" />
</DropdownButton>
<DropdownDivider>New</DropdownDivider>
<DropdownItem onClick={createNewCollection}>
<i className="fa fa-bars" />
Request Collection
</DropdownItem>
<DropdownItem onClick={createNewDocument}>
<i className="fa fa-file-o" />
Design Document
</DropdownItem>
<DropdownDivider>Import From</DropdownDivider>
<DropdownItem onClick={importFromFile}>
<i className="fa fa-plus" />
File
</DropdownItem>
<DropdownItem onClick={importFromURL}>
<i className="fa fa-link" />
URL
</DropdownItem>
<DropdownItem onClick={importFromClipboard}>
<i className="fa fa-clipboard" />
Clipboard
</DropdownItem>
<DropdownItem onClick={importFromGit}>
<i className="fa fa-code-fork" />
Git Clone
</DropdownItem>
</Dropdown>
</div>
<DashboardSortDropdown
value={sortOrder}
onSelect={sortOrder => {
dispatch(setDashboardSortOrder(sortOrder));
}}
/>
<RemoteWorkspacesDropdown vcs={vcs} />
<Dropdown>
<DropdownButton buttonClass={CreateButton}>
Create <i className="fa fa-caret-down pad-left-sm" />
</DropdownButton>
<DropdownDivider>New</DropdownDivider>
<DropdownItem
onClick={createRequestCollection}
>
<i className="fa fa-bars" />
Request Collection
</DropdownItem>
<DropdownItem
onClick={createDesignDocument}
>
<i className="fa fa-file-o" />
Design Document
</DropdownItem>
<DropdownDivider>Import From</DropdownDivider>
<DropdownItem
onClick={importFromFile}
>
<i className="fa fa-plus" />
File
</DropdownItem>
<DropdownItem
onClick={importFromURL}
>
<i className="fa fa-link" />
URL
</DropdownItem>
<DropdownItem
onClick={importFromClipboard}
>
<i className="fa fa-clipboard" />
Clipboard
</DropdownItem>
<DropdownItem
onClick={importFromGit}
>
<i className="fa fa-code-fork" />
Git Clone
</DropdownItem>
</Dropdown>
</div>
<CardContainer>
{workspaces.map(workspace => (
<WorkspaceCard
{...workspace}
projects={projects}
key={workspace.apiSpec._id}
activeProject={activeProject}
onSelect={() =>
navigate(
`/project/${activeProject._id}/workspace/${
workspace.workspace._id
}/${
workspace.workspace.scope === 'design'
? ACTIVITY_SPEC
: ACTIVITY_DEBUG
}`
)
}
filter={filter}
/>
))}
</CardContainer>
{filter && workspaces.length === 0 && (
<p className="notice subtle">
No documents found for <strong>{filter}</strong>
</p>
)}
{!filter && !workspaces.length && (
<WrapperHomeEmptyStatePane
createRequestCollection={createNewCollection}
createDesignDocument={createNewDocument}
importFromFile={importFromFile}
importFromURL={importFromURL}
importFromClipboard={importFromClipboard}
importFromGit={importFromGit}
/>
)}
</div>
<div className="document-listing__footer vertically-center">
<a className="made-with-love" href="https://konghq.com/">
Made with&nbsp;
<SvgIcon icon="heart" />
&nbsp;by Kong
</a>
</div>
<CardContainer>{cards}</CardContainer>
{filter && cards.length === 0 && (
<p className="notice subtle">
No documents found for <strong>{filter}</strong>
</p>
)}
{!filter && !cards.length && (
<WrapperHomeEmptyStatePane
createRequestCollection={createRequestCollection}
createDesignDocument={createDesignDocument}
importFromFile={importFromFile}
importFromURL={importFromURL}
importFromClipboard={importFromClipboard}
importFromGit={importFromGit}
/>
)}
</div>
<div className="document-listing__footer vertically-center">
<a className="made-with-love" href="https://konghq.com/">
Made with&nbsp;<SvgIcon icon="heart" />&nbsp;by Kong
</a>
</div>
</div>
}
/>
}
/>
</Fragment>
);
});
};
export default WrapperHome;
export default ProjectRoute;
@@ -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<RemoteProject>(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,
};
};
+7 -174
View File
@@ -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<State>({
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 = () => {
<AppHooks />
<div className="app" key={uniquenessKey}>
<ErrorBoundary showAlert>
<div key="modals" className="modals">
<ErrorBoundary showAlert>
<AnalyticsModal />
<AlertModal ref={instance => registerModal(instance, 'AlertModal')} />
<ErrorModal ref={instance => registerModal(instance, 'ErrorModal')} />
<PromptModal ref={instance => registerModal(instance, 'PromptModal')} />
<WrapperModal ref={instance => registerModal(instance, 'WrapperModal')} />
<LoginModal ref={registerModal} />
<AskModal ref={instance => registerModal(instance, 'AskModal')} />
<SelectModal ref={instance => registerModal(instance, 'SelectModal')} />
<FilterHelpModal ref={instance => registerModal(instance, 'FilterHelpModal')} />
<RequestRenderErrorModal ref={instance => registerModal(instance, 'RequestRenderErrorModal')} />
<GenerateConfigModal ref={instance => registerModal(instance, 'GenerateConfigModal')} />
<ProjectSettingsModal ref={instance => registerModal(instance, 'ProjectSettingsModal')} />
<WorkspaceDuplicateModal ref={instance => registerModal(instance, 'WorkspaceDuplicateModal')} />
<CodePromptModal ref={instance => registerModal(instance, 'CodePromptModal')} />
<RequestSettingsModal ref={instance => registerModal(instance, 'RequestSettingsModal')} />
<RequestGroupSettingsModal ref={instance => registerModal(instance, 'RequestGroupSettingsModal')} />
{activeWorkspace ? <>
{/* TODO: Figure out why cookieJar is sometimes null */}
{activeCookieJar ? <>
<CookiesModal
ref={instance => registerModal(instance, 'CookiesModal')}
/>
<CookieModifyModal ref={instance => registerModal(instance, 'CookieModifyModal')} />
</> : null}
<NunjucksModal
ref={instance => registerModal(instance, 'NunjucksModal')}
workspace={activeWorkspace}
/>
{activeApiSpec ? <WorkspaceSettingsModal
ref={instance => registerModal(instance, 'WorkspaceSettingsModal')}
/> : null}
</> : null}
<GenerateCodeModal
ref={instance => registerModal(instance, 'GenerateCodeModal')}
environmentId={activeEnvironment ? activeEnvironment._id : 'n/a'}
/>
<SettingsModal ref={instance => registerModal(instance, 'SettingsModal')} />
<ResponseDebugModal ref={instance => registerModal(instance, 'ResponseDebugModal')} />
<RequestSwitcherModal ref={instance => registerModal(instance, 'RequestSwitcherModal')} />
<EnvironmentEditModal ref={instance => registerModal(instance, 'EnvironmentEditModal')} />
<GitRepositorySettingsModal ref={instance => registerModal(instance, 'GitRepositorySettingsModal')} />
{activeWorkspace && gitVCS ? (
<Fragment>
<GitStagingModal ref={instance => registerModal(instance, 'GitStagingModal')} workspace={activeWorkspace} vcs={gitVCS} gitRepository={activeGitRepository} />
<GitLogModal ref={instance => registerModal(instance, 'GitLogModal')} vcs={gitVCS} />
{activeGitRepository !== null && (
<GitBranchesModal
ref={instance => registerModal(instance, 'GitBranchesModal')}
vcs={gitVCS}
/>
)}
</Fragment>
) : null}
{activeWorkspace && vcs ? (
<Fragment>
<SyncStagingModal ref={instance => registerModal(instance, 'SyncStagingModal')} vcs={vcs} />
<SyncMergeModal ref={instance => registerModal(instance, 'SyncMergeModal')} />
<SyncBranchesModal ref={instance => registerModal(instance, 'SyncBranchesModal')} vcs={vcs} />
<SyncDeleteModal ref={instance => registerModal(instance, 'SyncDeleteModal')} vcs={vcs} />
<SyncHistoryModal ref={instance => registerModal(instance, 'SyncHistoryModal')} vcs={vcs} />
</Fragment>
) : null}
<WorkspaceEnvironmentsEditModal ref={instance => registerModal(instance, 'WorkspaceEnvironmentsEditModal')} />
<AddKeyCombinationModal ref={instance => registerModal(instance, 'AddKeyCombinationModal')} />
<ExportRequestsModal ref={instance => registerModal(instance, 'ExportRequestsModal')} />
<GrpcDispatchModalWrapper>
{dispatch => (
<ProtoFilesModal
ref={instance => registerModal(instance, 'ProtoFilesModal')}
grpcDispatch={dispatch}
/>
)}
</GrpcDispatchModalWrapper>
</ErrorBoundary>
</div>
<Modals />
<Outlet />
<ActivityRouter />
</ErrorBoundary>
<ErrorBoundary showAlert>
@@ -262,4 +95,4 @@ const App = () => {
);
};
export default withDragDropContext(App);
export default withDragDropContext(Root);