mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-27 17:19:42 -04:00
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:
1 parent
1a09035743
commit
40d294e8ae
19 files changed
+1699
-935
No files matched your search
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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?.();
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
<SvgIcon icon="heart" />
|
||||
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 <SvgIcon icon="heart" /> 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user