From 37815afd2e8774fe1da9211367f3cc7cfaba2c60 Mon Sep 17 00:00:00 2001 From: Opender Singh Date: Thu, 29 Jul 2021 09:49:02 +1200 Subject: [PATCH] Duplicate from dashboard and from settings (#3820) --- packages/insomnia-app/app/common/constants.ts | 21 ++- packages/insomnia-app/app/common/strings.ts | 15 ++ .../models/helpers/workspace-operations.ts | 3 +- .../vcs/__tests__/initialize-project.test.ts | 32 ++++ .../app/sync/vcs/initialize-project.ts | 15 +- .../app/ui/components/base/modal.tsx | 3 + .../components/dropdowns/space-dropdown.tsx | 6 +- .../dropdowns/workspace-card-dropdown.tsx | 19 +-- .../modals/workspace-duplicate-modal.tsx | 157 +++++++++++++++++ .../modals/workspace-settings-modal.tsx | 7 +- .../app/ui/components/wrapper-home.tsx | 5 +- .../app/ui/components/wrapper.tsx | 5 +- .../insomnia-app/app/ui/containers/app.tsx | 39 +---- .../redux/modules/__tests__/workspace.test.ts | 159 ++++++++++++++++-- .../app/ui/redux/modules/global.tsx | 2 +- .../app/ui/redux/modules/workspace.ts | 37 +++- .../insomnia-app/app/ui/redux/selectors.ts | 20 +++ packages/insomnia-app/package-lock.json | 113 ++++++++++++- packages/insomnia-app/package.json | 2 + 19 files changed, 572 insertions(+), 88 deletions(-) create mode 100644 packages/insomnia-app/app/ui/components/modals/workspace-duplicate-modal.tsx diff --git a/packages/insomnia-app/app/common/constants.ts b/packages/insomnia-app/app/common/constants.ts index aa117caec1..efd75b4a09 100644 --- a/packages/insomnia-app/app/common/constants.ts +++ b/packages/insomnia-app/app/common/constants.ts @@ -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: diff --git a/packages/insomnia-app/app/common/strings.ts b/packages/insomnia-app/app/common/strings.ts index d1c57ef5a5..07a78b7dd6 100644 --- a/packages/insomnia-app/app/common/strings.ts +++ b/packages/insomnia-app/app/common/strings.ts @@ -9,6 +9,9 @@ type StringId = | 'home' | 'space' | 'workspace' + | 'baseSpace' + | 'localSpace' + | 'remoteSpace' ; export const strings: Record = { @@ -32,4 +35,16 @@ export const strings: Record = { singular: 'Workspace', plural: 'Workspaces', }, + baseSpace: { + singular: 'Base', + plural: 'Base', + }, + localSpace: { + singular: 'Local', + plural: 'Local', + }, + remoteSpace: { + singular: 'Remote', + plural: 'Remote', + }, }; diff --git a/packages/insomnia-app/app/models/helpers/workspace-operations.ts b/packages/insomnia-app/app/models/helpers/workspace-operations.ts index 86177cfb25..e9f3c54d63 100644 --- a/packages/insomnia-app/app/models/helpers/workspace-operations.ts +++ b/packages/insomnia-app/app/models/helpers/workspace-operations.ts @@ -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) { const newWorkspace = await db.duplicate(w, { name, + parentId, }); await models.apiSpec.updateOrCreateForParentId(newWorkspace._id, { fileName: name, diff --git a/packages/insomnia-app/app/sync/vcs/__tests__/initialize-project.test.ts b/packages/insomnia-app/app/sync/vcs/__tests__/initialize-project.test.ts index a54452616c..cba5ef0b59 100644 --- a/packages/insomnia-app/app/sync/vcs/__tests__/initialize-project.test.ts +++ b/packages/insomnia-app/app/sync/vcs/__tests__/initialize-project.test.ts @@ -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 }); diff --git a/packages/insomnia-app/app/sync/vcs/initialize-project.ts b/packages/insomnia-app/app/sync/vcs/initialize-project.ts index 648922647c..9cc16fbf03 100644 --- a/packages/insomnia-app/app/sync/vcs/initialize-project.ts +++ b/packages/insomnia-app/app/sync/vcs/initialize-project.ts @@ -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); } diff --git a/packages/insomnia-app/app/ui/components/base/modal.tsx b/packages/insomnia-app/app/ui/components/base/modal.tsx index 38f9b85e79..176e83cb2f 100644 --- a/packages/insomnia-app/app/ui/components/base/modal.tsx +++ b/packages/insomnia-app/app/ui/components/base/modal.tsx @@ -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 { forceRefreshCounter: forceRefreshCounter + (freshState ? 1 : 0), }); + this.props.onShow?.(); + if (this.props.dontFocus) { return; } diff --git a/packages/insomnia-app/app/ui/components/dropdowns/space-dropdown.tsx b/packages/insomnia-app/app/ui/components/dropdowns/space-dropdown.tsx index 33c916a691..23ad24cf8c 100644 --- a/packages/insomnia-app/app/ui/components/dropdowns/space-dropdown.tsx +++ b/packages/insomnia-app/app/ui/components/dropdowns/space-dropdown.tsx @@ -49,9 +49,9 @@ const TooltipIcon = ({ message, icon }: { message: string, icon: SvgIconProps['i ); const spinner = ; -const home = ; -const remoteSpace = ; -const localSpace = ; +const home = ; +const remoteSpace = ; +const localSpace = ; interface Props { vcs?: VCS; diff --git a/packages/insomnia-app/app/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia-app/app/ui/components/dropdowns/workspace-card-dropdown.tsx index 9130028e98..d0a911c55f 100644 --- a/packages/insomnia-app/app/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia-app/app/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -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 = ; 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({ diff --git a/packages/insomnia-app/app/ui/components/modals/workspace-duplicate-modal.tsx b/packages/insomnia-app/app/ui/components/modals/workspace-duplicate-modal.tsx new file mode 100644 index 0000000000..9ce631da25 --- /dev/null +++ b/packages/insomnia-app/app/ui/components/modals/workspace-duplicate-modal.tsx @@ -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 => ( + +); + +const WorkspaceDuplicateModalInternalWithRef: ForwardRefRenderFunction = ({ 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({ + 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 + {title} + +
+
+ +
+
+ +
+
+
+ + + +
; +}; + +const WorkspaceDuplicateModalInternal = forwardRef(WorkspaceDuplicateModalInternalWithRef); + +interface Props { + vcs?: VCS; +} + +interface State { + options?: Options; +} + +@autoBindMethodsForReact(AUTOBIND_CFG) +export class WorkspaceDuplicateModal extends PureComponent { + state: State = { }; + modal = createRef(); + + show(options: Options) { + this.setState({ options }, () => { + this.modal?.current?.show(); + }); + } + + hide() { + this.modal?.current?.hide(); + } + + render() { + if (this.state.options) { + return ; + } else { + return null; + } + } +} + +export const showWorkspaceDuplicateModal = (options: Options) => showModal(WorkspaceDuplicateModal, options); diff --git a/packages/insomnia-app/app/ui/components/modals/workspace-settings-modal.tsx b/packages/insomnia-app/app/ui/components/modals/workspace-settings-modal.tsx index aeeba732c2..6d851a2ab0 100644 --- a/packages/insomnia-app/app/ui/components/modals/workspace-settings-modal.tsx +++ b/packages/insomnia-app/app/ui/components/modals/workspace-settings-modal.tsx @@ -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 { } _handleDuplicateWorkspace() { - this.props.handleDuplicateWorkspace(() => { - this.hide(); - }); + const { workspace, apiSpec } = this.props; + showWorkspaceDuplicateModal({ workspace, apiSpec, onDone: this.hide }); } _handleToggleCertificateForm() { diff --git a/packages/insomnia-app/app/ui/components/wrapper-home.tsx b/packages/insomnia-app/app/ui/components/wrapper-home.tsx index dcbec0c5ff..6f95ff9e70 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-home.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper-home.tsx @@ -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 { 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 }); } }, diff --git a/packages/insomnia-app/app/ui/components/wrapper.tsx b/packages/insomnia-app/app/ui/components/wrapper.tsx index ec4d8dadb8..06e97ccf5c 100644 --- a/packages/insomnia-app/app/ui/components/wrapper.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper.tsx @@ -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 { activity, gitVCS, handleActivateRequest, - handleDuplicateWorkspace, handleExportRequestsToFile, handleGetRenderContext, handleInitializeEntities, @@ -538,6 +537,7 @@ class Wrapper extends PureComponent { + { handleGetRenderContext={handleGetRenderContext} nunjucksPowerUserMode={settings.nunjucksPowerUserMode} handleRemoveWorkspace={this._handleRemoveActiveWorkspace} - handleDuplicateWorkspace={handleDuplicateWorkspace} handleClearAllResponses={this._handleActiveWorkspaceClearAllResponses} isVariableUncovered={isVariableUncovered} /> : null} diff --git a/packages/insomnia-app/app/ui/containers/app.tsx b/packages/insomnia-app/app/ui/containers/app.tsx index 9476a5766f..1af120616d 100644 --- a/packages/insomnia-app/app/ui/containers/app.tsx +++ b/packages/insomnia-app/app/ui/containers/app.tsx @@ -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 { }); } - _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 { 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 { .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 { handleGetRenderContext={this._handleGetRenderContext} handleDuplicateRequest={this._requestDuplicate} handleDuplicateRequestGroup={App._requestGroupDuplicate} - handleDuplicateWorkspace={this._workspaceDuplicate} handleCreateRequestGroup={this._requestGroupCreate} handleGenerateCode={App._handleGenerateCode} handleGenerateCodeForActiveRequest={this._handleGenerateCodeForActiveRequest} diff --git a/packages/insomnia-app/app/ui/redux/modules/__tests__/workspace.test.ts b/packages/insomnia-app/app/ui/redux/modules/__tests__/workspace.test.ts index b6bb1e0534..fe3c342157 100644 --- a/packages/insomnia-app/app/ui/redux/modules/__tests__/workspace.test.ts +++ b/packages/insomnia-app/app/ui/redux/modules/__tests__/workspace.test.ts @@ -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, + }, + ]); + }); + }); }); diff --git a/packages/insomnia-app/app/ui/redux/modules/global.tsx b/packages/insomnia-app/app/ui/redux/modules/global.tsx index 703013a755..01e76f71f7 100644 --- a/packages/insomnia-app/app/ui/redux/modules/global.tsx +++ b/packages/insomnia-app/app/ui/redux/modules/global.tsx @@ -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, diff --git a/packages/insomnia-app/app/ui/redux/modules/workspace.ts b/packages/insomnia-app/app/ui/redux/modules/workspace.ts index 31d62e05f0..3e032ca628 100644 --- a/packages/insomnia-app/app/ui/redux/modules/workspace.ts +++ b/packages/insomnia-app/app/ui/redux/modules/workspace.ts @@ -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; @@ -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)); + } + }; +}; \ No newline at end of file diff --git a/packages/insomnia-app/app/ui/redux/selectors.ts b/packages/insomnia-app/app/ui/redux/selectors.ts index 532b12fc7e..aaf6a8e3ef 100644 --- a/packages/insomnia-app/app/ui/redux/selectors.ts +++ b/packages/insomnia-app/app/ui/redux/selectors.ts @@ -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, +); \ No newline at end of file diff --git a/packages/insomnia-app/package-lock.json b/packages/insomnia-app/package-lock.json index fe2b917b85..40b62ef1c7 100644 --- a/packages/insomnia-app/package-lock.json +++ b/packages/insomnia-app/package-lock.json @@ -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", diff --git a/packages/insomnia-app/package.json b/packages/insomnia-app/package.json index a3f120d38b..6e110676b1 100644 --- a/packages/insomnia-app/package.json +++ b/packages/insomnia-app/package.json @@ -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",