Duplicate from dashboard and from settings (#3820)

This commit is contained in:
Opender Singh
2021-07-29 09:49:02 +12:00
committed by GitHub
parent 6431378392
commit 37815afd2e
19 changed files with 572 additions and 88 deletions

View File

@@ -141,7 +141,10 @@ export const ACTIVITY_MIGRATION: GlobalActivity = 'migration';
export const ACTIVITY_ANALYTICS: GlobalActivity = 'analytics';
export const DEPRECATED_ACTIVITY_INSOMNIA = 'insomnia';
export const isWorkspaceActivity = (activity: string): activity is GlobalActivity => {
export const isWorkspaceActivity = (activity?: string): activity is GlobalActivity =>
isDesignActivity(activity) || isCollectionActivity(activity);
export const isDesignActivity = (activity?: string): activity is GlobalActivity => {
switch (activity) {
case ACTIVITY_SPEC:
case ACTIVITY_DEBUG:
@@ -157,6 +160,22 @@ export const isWorkspaceActivity = (activity: string): activity is GlobalActivit
}
};
export const isCollectionActivity = (activity?: string): activity is GlobalActivity => {
switch (activity) {
case ACTIVITY_DEBUG:
return true;
case ACTIVITY_SPEC:
case ACTIVITY_UNIT_TEST:
case ACTIVITY_HOME:
case ACTIVITY_ONBOARDING:
case ACTIVITY_MIGRATION:
case ACTIVITY_ANALYTICS:
default:
return false;
}
};
export const isValidActivity = (activity: string): activity is GlobalActivity => {
switch (activity) {
case ACTIVITY_SPEC:

View File

@@ -9,6 +9,9 @@ type StringId =
| 'home'
| 'space'
| 'workspace'
| 'baseSpace'
| 'localSpace'
| 'remoteSpace'
;
export const strings: Record<StringId, StringInfo> = {
@@ -32,4 +35,16 @@ export const strings: Record<StringId, StringInfo> = {
singular: 'Workspace',
plural: 'Workspaces',
},
baseSpace: {
singular: 'Base',
plural: 'Base',
},
localSpace: {
singular: 'Local',
plural: 'Local',
},
remoteSpace: {
singular: 'Remote',
plural: 'Remote',
},
};

View File

@@ -15,9 +15,10 @@ export async function rename(w: Workspace, s: ApiSpec, name: string) {
}
}
export async function duplicate(w: Workspace, name: string) {
export async function duplicate(w: Workspace, { name, parentId }: Pick<Workspace, 'name' | 'parentId'>) {
const newWorkspace = await db.duplicate(w, {
name,
parentId,
});
await models.apiSpec.updateOrCreateForParentId(newWorkspace._id, {
fileName: name,

View File

@@ -14,6 +14,22 @@ describe('initialize-project', () => {
beforeEach(globalBeforeEach);
describe('initializeLocalProjectAndMarkForSync()', () => {
it('should do nothing if not request collection', async () => {
// Arrange
const workspace = await models.workspace.create({ scope: 'design' });
await models.workspace.ensureChildren(workspace);
const vcs = new VCS(new MemoryDriver());
const switchAndCreateProjectIfNotExistSpy = jest.spyOn(vcs, 'switchAndCreateProjectIfNotExist');
// Act
await initializeLocalProjectAndMarkForSync({ workspace, vcs });
// Assert
expect(switchAndCreateProjectIfNotExistSpy).not.toHaveBeenCalled();
const workspaceMeta = await models.workspaceMeta.getByParentId(workspace._id);
expect(workspaceMeta?.pushSnapshotOnInitialize).toBe(false);
switchAndCreateProjectIfNotExistSpy.mockClear();
});
it('should create a local project and commit', async () => {
const workspace = await models.workspace.create();
await models.workspace.ensureChildren(workspace);
@@ -63,10 +79,23 @@ describe('initialize-project', () => {
pushSpy.mockClear();
});
it('should not push if no active project', async () => {
const space = await models.space.create({ remoteId: null });
const workspace = await models.workspace.create({ parentId: space._id });
const workspaceMeta = await models.workspaceMeta.create({ parentId: workspace._id });
vcs.clearProject();
await pushSnapshotOnInitialize({ vcs, space, workspace, workspaceMeta });
expect(pushSpy).not.toHaveBeenCalled();
await expect(models.workspaceMeta.getByParentId(workspace._id)).resolves.toStrictEqual(workspaceMeta);
});
it('should not push snapshot if not remote space', async () => {
const space = await models.space.create({ remoteId: null });
const workspace = await models.workspace.create({ parentId: space._id });
const workspaceMeta = await models.workspaceMeta.create({ parentId: workspace._id });
vcs.switchAndCreateProjectIfNotExist(workspace._id, workspace.name);
await pushSnapshotOnInitialize({ vcs, space, workspace, workspaceMeta });
@@ -79,6 +108,7 @@ describe('initialize-project', () => {
const anotherSpace = await models.space.create({ remoteId: 'def' });
const workspace = await models.workspace.create({ parentId: anotherSpace._id });
const workspaceMeta = await models.workspaceMeta.create({ parentId: workspace._id });
vcs.switchAndCreateProjectIfNotExist(workspace._id, workspace.name);
await pushSnapshotOnInitialize({ vcs, space, workspace, workspaceMeta });
@@ -90,6 +120,7 @@ describe('initialize-project', () => {
const space = await models.space.create({ remoteId: 'abc' });
const workspace = await models.workspace.create({ parentId: space._id });
const workspaceMeta = await models.workspaceMeta.create({ parentId: workspace._id, pushSnapshotOnInitialize: false });
vcs.switchAndCreateProjectIfNotExist(workspace._id, workspace.name);
await pushSnapshotOnInitialize({ vcs, space, workspace, workspaceMeta });
@@ -101,6 +132,7 @@ describe('initialize-project', () => {
const space = await models.space.create({ remoteId: 'abc' });
const workspace = await models.workspace.create({ parentId: space._id });
const workspaceMeta = await models.workspaceMeta.create({ parentId: workspace._id, pushSnapshotOnInitialize: true });
vcs.switchAndCreateProjectIfNotExist(workspace._id, workspace.name);
await pushSnapshotOnInitialize({ vcs, space, workspace, workspaceMeta });

View File

@@ -2,13 +2,18 @@ import { database } from '../../common/database';
import * as models from '../../models';
import { getStatusCandidates } from '../../models/helpers/get-status-candidates';
import { Space } from '../../models/space';
import { Workspace } from '../../models/workspace';
import { isCollection, Workspace } from '../../models/workspace';
import { WorkspaceMeta } from '../../models/workspace-meta';
import { VCS } from './vcs';
const blankStage = {};
export const initializeLocalProjectAndMarkForSync = async ({ vcs, workspace }: { vcs: VCS; workspace: Workspace; }) => {
if (!isCollection(workspace)) {
// Don't initialize and mark for sync unless we're in a collection
return;
}
// Create local project
await vcs.switchAndCreateProjectIfNotExist(workspace._id, workspace.name);
@@ -39,8 +44,14 @@ export const pushSnapshotOnInitialize = async ({
}) => {
const spaceIsForWorkspace = spaceId === workspace.parentId;
const markedForPush = workspaceMeta?.pushSnapshotOnInitialize;
// A race condition occurs in App.tsx when updating the active workspace
// One code path is that a React Key updates, forcing all children to unmount and remount (https://github.com/Kong/insomnia/blob/9a943879060927d6ab1c21d3e12daba39ad05eea/packages/insomnia-app/app/ui/containers/app.tsx#L1514-L1514)
// At the same time, we set VCS to null, then set it to the correct value, in state in App.tsx, forcing downstream updates (https://github.com/Kong/insomnia/blob/9a943879060927d6ab1c21d3e12daba39ad05eea/packages/insomnia-app/app/ui/containers/app.tsx#L1149-L1149)
// This race condition causes us to hit this codepath twice while activating a workspace but the first time it has no project so we shouldn't do anything
const hasProject = vcs.hasProject();
if (markedForPush && spaceIsForWorkspace && spaceRemoteId) {
if (markedForPush && spaceIsForWorkspace && spaceRemoteId && hasProject) {
await models.workspaceMeta.updateByParentId(workspace._id, { pushSnapshotOnInitialize: false });
await vcs.push(spaceRemoteId);
}

View File

@@ -17,6 +17,7 @@ export interface ModalProps {
noEscape?: boolean,
dontFocus?: boolean,
closeOnKeyCodes?: any[],
onShow?: Function,
onHide?: Function,
onCancel?: Function,
onKeyDown?: Function,
@@ -107,6 +108,8 @@ class Modal extends PureComponent<ModalProps, State> {
forceRefreshCounter: forceRefreshCounter + (freshState ? 1 : 0),
});
this.props.onShow?.();
if (this.props.dontFocus) {
return;
}

View File

@@ -49,9 +49,9 @@ const TooltipIcon = ({ message, icon }: { message: string, icon: SvgIconProps['i
);
const spinner = <i className="fa fa-spin fa-refresh" />;
const home = <TooltipIcon message={`Base ${strings.space.singular} (Always Local)`} icon="home" />;
const remoteSpace = <TooltipIcon message={`Remote ${strings.space.singular}`} icon="globe" />;
const localSpace = <TooltipIcon message={`Local ${strings.space.singular}`} icon="laptop" />;
const home = <TooltipIcon message={`${strings.baseSpace.singular} ${strings.space.singular} (Always ${strings.localSpace.singular})`} icon="home" />;
const remoteSpace = <TooltipIcon message={`${strings.remoteSpace.singular} ${strings.space.singular}`} icon="globe" />;
const localSpace = <TooltipIcon message={`${strings.localSpace.singular} ${strings.space.singular}`} icon="laptop" />;
interface Props {
vcs?: VCS;

View File

@@ -1,6 +1,5 @@
import { SvgIcon } from 'insomnia-components';
import React, { FC, useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { parseApiSpec } from '../../../common/api-specs';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
@@ -16,10 +15,10 @@ import type { DocumentAction } from '../../../plugins';
import { getDocumentActions } from '../../../plugins';
import * as pluginContexts from '../../../plugins/context';
import { useLoadingRecord } from '../../hooks/use-loading-record';
import { setActiveWorkspace } from '../../redux/modules/global';
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../base/dropdown';
import { showError, showModal, showPrompt } from '../modals';
import AskModal from '../modals/ask-modal';
import { showWorkspaceDuplicateModal } from '../modals/workspace-duplicate-modal';
interface Props {
workspace: Workspace;
@@ -30,21 +29,9 @@ interface Props {
const spinner = <i className="fa fa-refresh fa-spin" />;
const useWorkspaceHandlers = ({ workspace, apiSpec }: Props) => {
const dispatch = useDispatch();
const handleDuplicate = useCallback(() => {
showPrompt({
title: `Duplicate ${getWorkspaceLabel(workspace).singular}`,
defaultValue: getWorkspaceName(workspace, apiSpec),
submitName: 'Create',
selectText: true,
label: 'New Name',
onComplete: async newName => {
const newWorkspace = await workspaceOperations.duplicate(workspace, newName);
dispatch(setActiveWorkspace(newWorkspace._id));
},
});
}, [apiSpec, workspace, dispatch]);
showWorkspaceDuplicateModal({ workspace, apiSpec });
}, [apiSpec, workspace]);
const handleRename = useCallback(() => {
showPrompt({

View File

@@ -0,0 +1,157 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { createRef, FC, forwardRef, ForwardRefRenderFunction, PureComponent } from 'react';
import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useDispatch, useSelector } from 'react-redux';
import { AUTOBIND_CFG } from '../../../common/constants';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import { strings } from '../../../common/strings';
import * as models from '../../../models';
import { ApiSpec } from '../../../models/api-spec';
import getWorkspaceName from '../../../models/helpers/get-workspace-name';
import * as workspaceOperations from '../../../models/helpers/workspace-operations';
import { isBaseSpace, isLocalSpace, isRemoteSpace, Space } from '../../../models/space';
import { Workspace } from '../../../models/workspace';
import { initializeLocalProjectAndMarkForSync } from '../../../sync/vcs/initialize-project';
import { VCS } from '../../../sync/vcs/vcs';
import { activateWorkspace } from '../../redux/modules/workspace';
import { selectActiveSpace, selectIsLoggedIn, selectSpaces } from '../../redux/selectors';
import Modal from '../base/modal';
import ModalBody from '../base/modal-body';
import ModalFooter from '../base/modal-footer';
import ModalHeader from '../base/modal-header';
import { showModal } from '.';
interface Options {
workspace: Workspace;
apiSpec: ApiSpec;
onDone?: () => void;
}
interface FormFields {
newName: string;
spaceId: string;
}
interface InnerProps extends Options, Props {
hide: () => void,
}
const SpaceOption: FC<Space> = space => (
<option key={space._id} value={space._id}>
{space.name} ({isBaseSpace(space) ? strings.baseSpace.singular : isLocalSpace(space) ? strings.localSpace.singular : strings.remoteSpace.singular})
</option>
);
const WorkspaceDuplicateModalInternalWithRef: ForwardRefRenderFunction<Modal, InnerProps> = ({ workspace, apiSpec, onDone, hide, vcs }, ref) => {
const dispatch = useDispatch();
const spaces = useSelector(selectSpaces);
const activeSpace = useSelector(selectActiveSpace);
const isLoggedIn = useSelector(selectIsLoggedIn);
const title = `Duplicate ${getWorkspaceLabel(workspace).singular}`;
const defaultWorkspaceName = getWorkspaceName(workspace, apiSpec);
const {
register,
handleSubmit,
reset,
formState: {
errors,
} } = useForm<FormFields>({
defaultValues: {
newName: defaultWorkspaceName,
spaceId: activeSpace._id,
},
});
const onSubmit = useCallback(async ({ spaceId, newName }: FormFields) => {
const duplicateToSpace = spaces.find(space => space._id === spaceId);
if (!duplicateToSpace) {
throw new Error('Space could not be found');
}
const newWorkspace = await workspaceOperations.duplicate(workspace, { name: newName, parentId: spaceId });
await models.workspace.ensureChildren(newWorkspace);
// Mark for sync if logged in and in the expected space
if (isLoggedIn && vcs && isRemoteSpace(duplicateToSpace)) {
await initializeLocalProjectAndMarkForSync({ vcs: vcs.newInstance(), workspace: newWorkspace });
}
dispatch(activateWorkspace(newWorkspace));
hide();
onDone?.();
}, [dispatch, hide, isLoggedIn, onDone, spaces, vcs, workspace]);
return <Modal ref={ref} onShow={reset}>
<ModalHeader>{title}</ModalHeader>
<ModalBody className="wide">
<form className="wide pad" onSubmit={handleSubmit(onSubmit)}>
<div className="form-control form-control--wide form-control--outlined">
<label>
New Name
<input {...register('newName', { validate: v => Boolean(v.trim()) || 'Should not be blank' })} />
{errors.newName && <div className="font-error space-top">{errors.newName.message}</div>}
</label>
</div>
<div className="form-control form-control--outlined">
<label>
{strings.space.singular} to duplicate into
<select {...register('spaceId')}>
{spaces.map(SpaceOption)}
</select>
</label>
</div>
</form>
</ModalBody>
<ModalFooter>
<button className="btn" onClick={handleSubmit(onSubmit)}>
Duplicate
</button>
</ModalFooter>
</Modal>;
};
const WorkspaceDuplicateModalInternal = forwardRef(WorkspaceDuplicateModalInternalWithRef);
interface Props {
vcs?: VCS;
}
interface State {
options?: Options;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class WorkspaceDuplicateModal extends PureComponent<Props, State> {
state: State = { };
modal = createRef<Modal>();
show(options: Options) {
this.setState({ options }, () => {
this.modal?.current?.show();
});
}
hide() {
this.modal?.current?.hide();
}
render() {
if (this.state.options) {
return <WorkspaceDuplicateModalInternal
ref={this.modal}
{...this.state.options}
{...this.props}
hide={this.hide}
/>;
} else {
return null;
}
}
}
export const showWorkspaceDuplicateModal = (options: Options) => showModal(WorkspaceDuplicateModal, options);

View File

@@ -19,6 +19,7 @@ import ModalHeader from '../base/modal-header';
import PromptButton from '../base/prompt-button';
import HelpTooltip from '../help-tooltip';
import MarkdownEditor from '../markdown-editor';
import { showWorkspaceDuplicateModal } from './workspace-duplicate-modal';
interface Props {
clientCertificates: ClientCertificate[];
@@ -33,7 +34,6 @@ interface Props {
handleRender: HandleRender;
handleGetRenderContext: HandleGetRenderContext;
handleRemoveWorkspace: Function;
handleDuplicateWorkspace: Function;
handleClearAllResponses: Function;
}
@@ -90,9 +90,8 @@ class WorkspaceSettingsModal extends PureComponent<Props, State> {
}
_handleDuplicateWorkspace() {
this.props.handleDuplicateWorkspace(() => {
this.hide();
});
const { workspace, apiSpec } = this.props;
showWorkspaceDuplicateModal({ workspace, apiSpec, onDone: this.hide });
}
_handleToggleCertificateForm() {

View File

@@ -29,6 +29,7 @@ import { fuzzyMatchAll, isNotNullOrUndefined } from '../../common/misc';
import { descendingNumberSort } from '../../common/sorting';
import { strings } from '../../common/strings';
import * as models from '../../models';
import { isRemoteSpace } from '../../models/space';
import { isDesign, Workspace, WorkspaceScopeKeys } from '../../models/workspace';
import { MemClient } from '../../sync/git/mem-client';
import { initializeLocalProjectAndMarkForSync } from '../../sync/vcs/initialize-project';
@@ -95,10 +96,8 @@ class WrapperHome extends PureComponent<Props, State> {
handleCreateWorkspace({
scope: WorkspaceScopeKeys.collection,
onCreate: async workspace => {
const spaceRemoteId = activeSpace?.remoteId;
// Don't mark for sync if not logged in at the time of creation
if (isLoggedIn && vcs && spaceRemoteId) {
if (isLoggedIn && vcs && isRemoteSpace(activeSpace)) {
await initializeLocalProjectAndMarkForSync({ vcs: vcs.newInstance(), workspace });
}
},

View File

@@ -75,6 +75,7 @@ import SyncDeleteModal from './modals/sync-delete-modal';
import SyncHistoryModal from './modals/sync-history-modal';
import SyncMergeModal from './modals/sync-merge-modal';
import SyncStagingModal from './modals/sync-staging-modal';
import { WorkspaceDuplicateModal } from './modals/workspace-duplicate-modal';
import WorkspaceEnvironmentsEditModal from './modals/workspace-environments-edit-modal';
import WorkspaceSettingsModal from './modals/workspace-settings-modal';
import WrapperModal from './modals/wrapper-modal';
@@ -96,7 +97,6 @@ export type WrapperProps = AppProps & {
handleCreateRequest: (id: string) => void;
handleDuplicateRequest: Function;
handleDuplicateRequestGroup: (requestGroup: RequestGroup) => void;
handleDuplicateWorkspace: Function;
handleCreateRequestGroup: (parentId: string) => void;
handleGenerateCodeForActiveRequest: Function;
handleGenerateCode: Function;
@@ -479,7 +479,6 @@ class Wrapper extends PureComponent<WrapperProps, State> {
activity,
gitVCS,
handleActivateRequest,
handleDuplicateWorkspace,
handleExportRequestsToFile,
handleGetRenderContext,
handleInitializeEntities,
@@ -538,6 +537,7 @@ class Wrapper extends PureComponent<WrapperProps, State> {
<RequestRenderErrorModal ref={registerModal} />
<GenerateConfigModal ref={registerModal} settings={settings} />
<SpaceSettingsModal ref={registerModal} />
<WorkspaceDuplicateModal ref={registerModal} vcs={vcs || undefined} />
<CodePromptModal
ref={registerModal}
@@ -619,7 +619,6 @@ class Wrapper extends PureComponent<WrapperProps, State> {
handleGetRenderContext={handleGetRenderContext}
nunjucksPowerUserMode={settings.nunjucksPowerUserMode}
handleRemoveWorkspace={this._handleRemoveActiveWorkspace}
handleDuplicateWorkspace={handleDuplicateWorkspace}
handleClearAllResponses={this._handleActiveWorkspaceClearAllResponses}
isVariableUncovered={isVariableUncovered}
/> : null}

View File

@@ -32,7 +32,6 @@ import {
} from '../../common/constants';
import { database as db } from '../../common/database';
import { getDataDirectory } from '../../common/electron-helpers';
import { getWorkspaceLabel } from '../../common/get-workspace-label';
import { exportHarRequest } from '../../common/har';
import { hotKeyRefs } from '../../common/hotkeys';
import { executeHotKey } from '../../common/hotkeys-listener';
@@ -48,13 +47,12 @@ import * as models from '../../models';
import { isEnvironment } from '../../models/environment';
import { GrpcRequest, isGrpcRequest, isGrpcRequestId } from '../../models/grpc-request';
import { GrpcRequestMeta } from '../../models/grpc-request-meta';
import getWorkspaceName from '../../models/helpers/get-workspace-name';
import * as requestOperations from '../../models/helpers/request-operations';
import * as workspaceOperations from '../../models/helpers/workspace-operations';
import { Request, updateMimeType } from '../../models/request';
import { isRequestGroup, RequestGroup } from '../../models/request-group';
import { RequestMeta } from '../../models/request-meta';
import { Response } from '../../models/response';
import { isNotBaseSpace } from '../../models/space';
import { isCollection, isWorkspace } from '../../models/workspace';
import { WorkspaceMeta } from '../../models/workspace-meta';
import * as network from '../../network/network';
@@ -501,32 +499,6 @@ class App extends PureComponent<AppProps, State> {
});
}
_workspaceDuplicateById(callback: () => void, workspaceId: string) {
const workspace = this.props.workspaces.find(w => w._id === workspaceId);
const apiSpec = this.props.apiSpecs.find(s => s.parentId === workspaceId);
showPrompt({
// @ts-expect-error -- TSCONVERSION workspace can be null
title: `Duplicate ${getWorkspaceLabel(workspace).singular}`,
// @ts-expect-error -- TSCONVERSION workspace can be null
defaultValue: getWorkspaceName(workspace, apiSpec),
submitName: 'Create',
selectText: true,
label: 'New Name',
onComplete: async name => {
// @ts-expect-error -- TSCONVERSION workspace can be null
const newWorkspace = await workspaceOperations.duplicate(workspace, name);
await this.props.handleSetActiveWorkspace(newWorkspace._id);
callback();
},
});
}
_workspaceDuplicate(callback: () => void) {
if (this.props.activeWorkspace) {
this._workspaceDuplicateById(callback, this.props.activeWorkspace._id);
}
}
async _fetchRenderContext() {
const { activeEnvironment, activeRequest, activeWorkspace } = this.props;
const ancestors = await render.getRenderContextAncestors(activeRequest || activeWorkspace);
@@ -1367,7 +1339,9 @@ class App extends PureComponent<AppProps, State> {
const bufferId = await db.bufferChanges();
console.log(`[developer] clearing all "${type}" entities`);
const allEntities = await db.all(type);
await db.batchModifyDocs({ remove: allEntities });
const filteredEntites = allEntities
.filter(isNotBaseSpace); // don't clear the base space
await db.batchModifyDocs({ remove: filteredEntites });
db.flushChanges(bufferId);
}
},
@@ -1389,7 +1363,9 @@ class App extends PureComponent<AppProps, State> {
.reverse().map(async type => {
console.log(`[developer] clearing all "${type}" entities`);
const allEntities = await db.all(type);
await db.batchModifyDocs({ remove: allEntities });
const filteredEntites = allEntities
.filter(isNotBaseSpace); // don't clear the base space
await db.batchModifyDocs({ remove: filteredEntites });
});
await Promise.all(promises);
db.flushChanges(bufferId);
@@ -1565,7 +1541,6 @@ class App extends PureComponent<AppProps, State> {
handleGetRenderContext={this._handleGetRenderContext}
handleDuplicateRequest={this._requestDuplicate}
handleDuplicateRequestGroup={App._requestGroupDuplicate}
handleDuplicateWorkspace={this._workspaceDuplicate}
handleCreateRequestGroup={this._requestGroupCreate}
handleGenerateCode={App._handleGenerateCode}
handleGenerateCodeForActiveRequest={this._handleGenerateCodeForActiveRequest}

View File

@@ -4,7 +4,7 @@ import thunk from 'redux-thunk';
import { globalBeforeEach } from '../../../../__jest__/before-each';
import { reduxStateForTest } from '../../../../__jest__/redux-state-for-test';
import { trackEvent, trackSegmentEvent } from '../../../../common/analytics';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../../../common/constants';
import { ACTIVITY_DEBUG, ACTIVITY_HOME, ACTIVITY_SPEC, ACTIVITY_UNIT_TEST } from '../../../../common/constants';
import { database } from '../../../../common/database';
import * as models from '../../../../models';
import { ApiSpec } from '../../../../models/api-spec';
@@ -14,8 +14,8 @@ import { BASE_SPACE_ID } from '../../../../models/space';
import { Workspace, WorkspaceScope, WorkspaceScopeKeys } from '../../../../models/workspace';
import { WorkspaceMeta } from '../../../../models/workspace-meta';
import { getAndClearShowPromptMockArgs } from '../../../../test-utils';
import { SET_ACTIVE_ACTIVITY, SET_ACTIVE_WORKSPACE } from '../global';
import { createWorkspace } from '../workspace';
import { SET_ACTIVE_ACTIVITY, SET_ACTIVE_SPACE, SET_ACTIVE_WORKSPACE } from '../global';
import { activateWorkspace, createWorkspace } from '../workspace';
jest.mock('../../../components/modals');
jest.mock('../../../../common/analytics');
@@ -23,15 +23,6 @@ jest.mock('../../../../common/analytics');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
const createStoreWithSpace = async () => {
const space = await models.initModel(models.space.type);
const entities = { spaces: { [space._id]: space } };
const global = { activeSpaceId: space._id };
const store = mockStore({ entities, global });
return { store, space };
};
const expectedModelsCreated = async (name: string, scope: WorkspaceScope, parentId: string) => {
const workspaces = await models.workspace.all();
expect(workspaces).toHaveLength(1);
@@ -55,7 +46,8 @@ describe('workspace', () => {
beforeEach(globalBeforeEach);
describe('createWorkspace', () => {
it('should create document', async () => {
const { store, space } = await createStoreWithSpace();
const spaceId = BASE_SPACE_ID;
const store = mockStore(await reduxStateForTest({ activeSpaceId: spaceId }));
// @ts-expect-error redux-thunk types
store.dispatch(createWorkspace({ scope: WorkspaceScopeKeys.design }));
@@ -67,7 +59,7 @@ describe('workspace', () => {
const workspaceName = 'name';
await onComplete?.(workspaceName);
const workspaceId = await expectedModelsCreated(workspaceName, WorkspaceScopeKeys.design, space._id);
const workspaceId = await expectedModelsCreated(workspaceName, WorkspaceScopeKeys.design, spaceId);
expect(trackSegmentEvent).toHaveBeenCalledWith('Document Created');
expect(trackEvent).toHaveBeenCalledWith('Workspace', 'Create');
@@ -84,7 +76,8 @@ describe('workspace', () => {
});
it('should create collection', async () => {
const { store, space } = await createStoreWithSpace();
const spaceId = BASE_SPACE_ID;
const store = mockStore(await reduxStateForTest({ activeSpaceId: spaceId }));
// @ts-expect-error redux-thunk types
store.dispatch(createWorkspace({ scope: WorkspaceScopeKeys.collection }));
@@ -96,7 +89,7 @@ describe('workspace', () => {
const workspaceName = 'name';
await onComplete?.(workspaceName);
const workspaceId = await expectedModelsCreated(workspaceName, WorkspaceScopeKeys.collection, space._id);
const workspaceId = await expectedModelsCreated(workspaceName, WorkspaceScopeKeys.collection, spaceId);
expect(trackSegmentEvent).toHaveBeenCalledWith('Collection Created');
expect(trackEvent).toHaveBeenCalledWith('Workspace', 'Create');
@@ -141,4 +134,138 @@ describe('workspace', () => {
]);
});
});
describe('activateWorkspace', () => {
it('should activate space and workspace and activity from home', async () => {
const space = await models.space.create();
const workspace = await models.workspace.create({ scope: 'design', parentId: space._id });
const store = mockStore(await reduxStateForTest({ activeSpaceId: 'abc', activeWorkspaceId: 'def', activeActivity: ACTIVITY_HOME }));
await store.dispatch(activateWorkspace(workspace));
expect(store.getActions()).toEqual([
{
type: SET_ACTIVE_SPACE,
spaceId: space._id,
},
{
type: SET_ACTIVE_WORKSPACE,
workspaceId: workspace._id,
},
{
type: SET_ACTIVE_ACTIVITY,
activity: ACTIVITY_SPEC,
},
]);
});
it('should switch to the default design activity', async () => {
const space = await models.space.create();
const workspace = await models.workspace.create({ scope: 'design', parentId: space._id });
const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id, activeActivity: ACTIVITY_HOME }));
await store.dispatch(activateWorkspace(workspace));
expect(store.getActions()).toEqual([
{
type: SET_ACTIVE_SPACE,
spaceId: space._id,
},
{
type: SET_ACTIVE_WORKSPACE,
workspaceId: workspace._id,
},
{
type: SET_ACTIVE_ACTIVITY,
activity: ACTIVITY_SPEC,
},
]);
});
it.each([ACTIVITY_UNIT_TEST, ACTIVITY_SPEC, ACTIVITY_DEBUG])('should not switch activity if already in a supported design activity: %s', async activeActivity => {
const space = await models.space.create();
const workspace = await models.workspace.create({ scope: 'design', parentId: space._id });
const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id, activeActivity }));
await store.dispatch(activateWorkspace(workspace));
expect(store.getActions()).toEqual([
{
type: SET_ACTIVE_SPACE,
spaceId: space._id,
},
{
type: SET_ACTIVE_WORKSPACE,
workspaceId: workspace._id,
},
]);
});
it.each([ACTIVITY_DEBUG])('should not switch activity if already in a supported collection activity: %s', async activeActivity => {
const space = await models.space.create();
const workspace = await models.workspace.create({ scope: 'design', parentId: space._id });
const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id, activeActivity }));
await store.dispatch(activateWorkspace(workspace));
expect(store.getActions()).toEqual([
{
type: SET_ACTIVE_SPACE,
spaceId: space._id,
},
{
type: SET_ACTIVE_WORKSPACE,
workspaceId: workspace._id,
},
]);
});
it('should switch to the default collection activity', async () => {
const space = await models.space.create();
const workspace = await models.workspace.create({ scope: 'collection', parentId: space._id });
const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id, activeActivity: ACTIVITY_HOME }));
await store.dispatch(activateWorkspace(workspace));
expect(store.getActions()).toEqual([
{
type: SET_ACTIVE_SPACE,
spaceId: space._id,
},
{
type: SET_ACTIVE_WORKSPACE,
workspaceId: workspace._id,
},
{
type: SET_ACTIVE_ACTIVITY,
activity: ACTIVITY_DEBUG,
},
]);
});
it('should switch to the cached activity', async () => {
const space = await models.space.create();
const workspace = await models.workspace.create({ scope: 'design', parentId: space._id });
await models.workspace.ensureChildren(workspace);
await models.workspaceMeta.updateByParentId(workspace._id, { activeActivity: ACTIVITY_UNIT_TEST });
const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id, activeActivity: ACTIVITY_HOME }));
await store.dispatch(activateWorkspace(workspace));
expect(store.getActions()).toEqual([
{
type: SET_ACTIVE_SPACE,
spaceId: space._id,
},
{
type: SET_ACTIVE_WORKSPACE,
workspaceId: workspace._id,
},
{
type: SET_ACTIVE_ACTIVITY,
activity: ACTIVITY_UNIT_TEST,
},
]);
});
});
});

View File

@@ -7,7 +7,7 @@ import { combineReducers, Dispatch } from 'redux';
import { unreachableCase } from 'ts-assert-unreachable';
import { trackEvent } from '../../../common/analytics';
import type { GlobalActivity } from '../../../common/constants';
import { GlobalActivity } from '../../../common/constants';
import {
ACTIVITY_ANALYTICS,
ACTIVITY_DEBUG,

View File

@@ -1,13 +1,14 @@
import { Dispatch } from 'redux';
import { trackEvent, trackSegmentEvent } from '../../../common/analytics';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../../common/constants';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC, GlobalActivity, isCollectionActivity, isDesignActivity } from '../../../common/constants';
import { database } from '../../../common/database';
import * as models from '../../../models';
import { isDesign, Workspace, WorkspaceScope } from '../../../models/workspace';
import { isCollection, isDesign, Workspace, WorkspaceScope } from '../../../models/workspace';
import { showPrompt } from '../../components/modals';
import { selectActiveSpace } from '../selectors';
import { setActiveActivity, setActiveWorkspace } from './global';
import { selectActiveActivity, selectActiveSpace } from '../selectors';
import { RootState } from '.';
import { setActiveActivity, setActiveSpace, setActiveWorkspace } from './global';
type OnWorkspaceCreateCallback = (arg0: Workspace) => Promise<void> | void;
@@ -70,3 +71,31 @@ export const createWorkspace = ({ scope, onCreate }: {
});
};
};
export const activateWorkspace = (workspace: Workspace) => {
return async (dispatch: Dispatch, getState: () => RootState) => {
const activeActivity = selectActiveActivity(getState()) || undefined;
// Activate the correct space
const nextSpaceId = workspace.parentId;
dispatch(setActiveSpace(nextSpaceId));
// Activate the correct workspace
const nextWorkspaceId = workspace._id;
dispatch(setActiveWorkspace(nextWorkspaceId));
// Activate the correct activity
if (isCollection(workspace) && isCollectionActivity(activeActivity)) {
// we are in a collection, and our active activity is a collection activity
return;
} else if (isDesign(workspace) && isDesignActivity(activeActivity)) {
// we are in a design document, and our active activity is a design activity
return;
} else {
const { activeActivity: cachedActivity } = await models.workspaceMeta.getOrCreateByParentId(workspace._id);
const nextActivity = cachedActivity as GlobalActivity || (isDesign(workspace) ? ACTIVITY_SPEC : ACTIVITY_DEBUG);
dispatch(setActiveActivity(nextActivity));
}
};
};

View File

@@ -23,6 +23,11 @@ export const selectEntities = createSelector(
entities => entities,
);
export const selectGlobal = createSelector(
(state: RootState) => state.global,
global => global,
);
export const selectEntitiesLists = createSelector(
selectEntities,
entities => {
@@ -79,6 +84,11 @@ export const selectAllWorkspaces = createSelector(
entities => entities.workspaces,
);
export const selectAllApiSpecs = createSelector(
selectEntitiesLists,
entities => entities.apiSpecs,
);
export const selectWorkspacesForActiveSpace = createSelector(
selectAllWorkspaces,
selectActiveSpace,
@@ -378,3 +388,13 @@ export const selectSyncItems = createSelector(
selectActiveWorkspaceEntities,
getStatusCandidates,
);
export const selectIsLoggedIn = createSelector(
selectGlobal,
global => global.isLoggedIn,
);
export const selectActiveActivity = createSelector(
selectGlobal,
global => global.activeActivity,
);

View File

@@ -6968,12 +6968,54 @@
"verror": "^1.10.0"
},
"dependencies": {
"ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
"dev": true,
"optional": true
},
"cli-truncate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-1.1.0.tgz",
"integrity": "sha512-bAtZo0u82gCfaAGfSNxUdTI9mNyza7D8w4CVCcaOsy7sgwDzvx6ekr6cuWJqY3UGzgnQ1+4wgENup5eIhgxEYA==",
"dev": true,
"optional": true
"optional": true,
"requires": {
"slice-ansi": "^1.0.0",
"string-width": "^2.0.0"
}
},
"slice-ansi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz",
"integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==",
"dev": true,
"optional": true,
"requires": {
"is-fullwidth-code-point": "^2.0.0"
}
},
"string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
"integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
"dev": true,
"optional": true,
"requires": {
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^4.0.0"
}
},
"strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^3.0.0"
}
}
}
},
@@ -10364,7 +10406,62 @@
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.6.tgz",
"integrity": "sha512-1NBe55C75bKGZaY9UHxvXG3G0gEp0ziht7quhuFrW3SPgZDw9HI6qvYXRSV5M/Eupyu8ljuJ6Cba+ec15PZ4Xw==",
"dev": true,
"optional": true
"optional": true,
"requires": {
"cli-truncate": "^1.1.0",
"node-addon-api": "^1.6.3"
},
"dependencies": {
"ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
"dev": true,
"optional": true
},
"cli-truncate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-1.1.0.tgz",
"integrity": "sha512-bAtZo0u82gCfaAGfSNxUdTI9mNyza7D8w4CVCcaOsy7sgwDzvx6ekr6cuWJqY3UGzgnQ1+4wgENup5eIhgxEYA==",
"dev": true,
"optional": true,
"requires": {
"slice-ansi": "^1.0.0",
"string-width": "^2.0.0"
}
},
"slice-ansi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz",
"integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==",
"dev": true,
"optional": true,
"requires": {
"is-fullwidth-code-point": "^2.0.0"
}
},
"string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
"integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
"dev": true,
"optional": true,
"requires": {
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^4.0.0"
}
},
"strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^3.0.0"
}
}
}
},
"iconv-lite": {
"version": "0.4.24",
@@ -14331,6 +14428,13 @@
"jsep": "^0.3.4"
}
},
"node-addon-api": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
"integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
"dev": true,
"optional": true
},
"node-cache": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/node-cache/-/node-cache-3.2.1.tgz",
@@ -16080,6 +16184,11 @@
}
}
},
"react-hook-form": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.12.1.tgz",
"integrity": "sha512-JBu5TZK3IXzDKw9SuNFwyQFdIx5uGZSmN9QTDsNsDSYdccU/O+43jBUh0zKG4jDc4hiNYYgDw34lLt7qLSeusA=="
},
"react-hot-loader": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.13.0.tgz",

View File

@@ -60,6 +60,7 @@
"react-dnd",
"react-dnd-html5-backend",
"react-dom",
"react-hook-form",
"react-redux",
"react-sortable-hoc",
"react-tabs",
@@ -146,6 +147,7 @@
"react-dnd": "^7.4.5",
"react-dnd-html5-backend": "^7.4.4",
"react-dom": "^16.8.3",
"react-hook-form": "^7.12.1",
"react-redux": "^7.0.1",
"react-sortable-hoc": "^0.8.3",
"react-tabs": "^3.0.0",