diff --git a/packages/insomnia-app/app/__jest__/with-redux-store.tsx b/packages/insomnia-app/app/__jest__/with-redux-store.tsx new file mode 100644 index 0000000000..228761941d --- /dev/null +++ b/packages/insomnia-app/app/__jest__/with-redux-store.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { MockStoreEnhanced } from 'redux-mock-store'; + +import { RootState } from '../ui/redux/modules'; + +export const withReduxStore = (store: MockStoreEnhanced) => + class ReduxWrapper extends React.Component { + render() { + const { children } = this.props; + return {children}; + } + }; \ No newline at end of file diff --git a/packages/insomnia-app/app/models/space.ts b/packages/insomnia-app/app/models/space.ts index 3da241829e..5b06dfa993 100644 --- a/packages/insomnia-app/app/models/space.ts +++ b/packages/insomnia-app/app/models/space.ts @@ -11,18 +11,25 @@ export const canSync = false; export const BASE_SPACE_ID = `${prefix}_base-space`; -export const isBaseSpace = ({ _id }: Space) => _id === BASE_SPACE_ID; +export const isBaseSpace = (space: Space) => space._id === BASE_SPACE_ID; export const isNotBaseSpace = (space: Space) => !isBaseSpace(space); -export const isLocalSpace = ({ remoteId }: Space) => remoteId === null; -export const isRemoteSpace = (space: Space) => !isLocalSpace(space); +export const isLocalSpace = (space: Space): space is LocalSpace => space.remoteId === null; +export const isRemoteSpace = (space: Space): space is RemoteSpace => !isLocalSpace(space); export const spaceHasSettings = (space: Space) => isLocalSpace(space) && !isBaseSpace(space); -interface BaseSpace { +interface CommonSpace { name: string; - remoteId: string | null; } -export type Space = BaseModel & BaseSpace; +export interface RemoteSpace extends BaseModel, CommonSpace { + remoteId: string; +} + +export interface LocalSpace extends BaseModel, CommonSpace { + remoteId: null; +} + +export type Space = LocalSpace | RemoteSpace; export const isSpace = (model: Pick): model is Space => ( model.type === type @@ -32,7 +39,7 @@ export const isSpaceId = (id: string | null) => ( id?.startsWith(`${prefix}_`) ); -export function init(): BaseSpace { +export function init(): Partial { return { name: 'My Space', remoteId: null, // `null` is necessary for the model init logic to work properly @@ -55,6 +62,10 @@ export function getById(_id: string) { return db.getWhere(type, { _id }); } +export function getByRemoteId(remoteId: string) { + return db.getWhere(type, { remoteId }); +} + export function remove(obj: Space) { return db.remove(obj); } diff --git a/packages/insomnia-app/app/sync/__schemas__/type-schemas.ts b/packages/insomnia-app/app/sync/__schemas__/type-schemas.ts index 2407bc3a34..33d303c17a 100644 --- a/packages/insomnia-app/app/sync/__schemas__/type-schemas.ts +++ b/packages/insomnia-app/app/sync/__schemas__/type-schemas.ts @@ -1,7 +1,8 @@ import { createBuilder, Schema } from '@develohpanda/fluent-builder'; import { baseModelSchema } from '../../models/__schemas__/model-schemas'; -import { Branch, MergeConflict, Project, SnapshotStateEntry, StatusCandidate } from '../types'; +import { Branch, MergeConflict, Project, SnapshotStateEntry, StatusCandidate, Team } from '../types'; +import { ProjectWithTeam } from '../vcs/normalize-project-team'; export const projectSchema: Schema = { id: () => 'id', @@ -9,6 +10,16 @@ export const projectSchema: Schema = { name: () => 'name', }; +export const teamSchema: Schema = { + id: () => 'teamId', + name: () => 'teamName', +}; + +export const projectWithTeamSchema: Schema = { + ...projectSchema, + team: () => createBuilder(teamSchema).build(), +}; + export const branchSchema: Schema = { created: () => new Date(0), modified: () => new Date(0), diff --git a/packages/insomnia-app/app/sync/vcs/__tests__/initialize-space-from.test.ts b/packages/insomnia-app/app/sync/vcs/__tests__/initialize-space-from.test.ts new file mode 100644 index 0000000000..f6be4abe86 --- /dev/null +++ b/packages/insomnia-app/app/sync/vcs/__tests__/initialize-space-from.test.ts @@ -0,0 +1,53 @@ +import { createBuilder } from '@develohpanda/fluent-builder'; + +import { globalBeforeEach } from '../../../__jest__/before-each'; +import * as models from '../../../models'; +import { isRemoteSpace, Space } from '../../../models/space'; +import { isCollection, Workspace, WorkspaceScopeKeys } from '../../../models/workspace'; +import { projectSchema, teamSchema } from '../../__schemas__/type-schemas'; +import { initializeSpaceFromTeam, initializeWorkspaceFromProject } from '../initialize-model-from'; + +const teamBuilder = createBuilder(teamSchema); +const projectBuilder = createBuilder(projectSchema); + +describe('initializeSpaceFromTeam', () => { + beforeEach(globalBeforeEach); + + it('should initialize space with properties from the team', async () => { + // Arrange + const team = teamBuilder.build(); + + // Act + const space = await initializeSpaceFromTeam(team); + + // Assert + expect(isRemoteSpace(space)).toBe(true); + expect(space).toMatchObject>({ + _id:`${models.space.prefix}_${team.id}`, + remoteId: team.id, + name: team.name, + }); + }); +}); + +describe('initializeWorkspaceFromProject', () => { + beforeEach(globalBeforeEach); + + it('should initialize workspac with properties from the project and space', async () => { + // Arrange + const space = await models.space.create(); + const project = projectBuilder.build(); + + // Act + const workspace = await initializeWorkspaceFromProject(project, space); + + // Assert + expect(isCollection(workspace)).toBe(true); + expect(workspace).toMatchObject>({ + _id: project.rootDocumentId, + name: project.name, + parentId: space._id, + scope: WorkspaceScopeKeys.collection, + }); + }); +}); \ No newline at end of file diff --git a/packages/insomnia-app/app/sync/vcs/__tests__/pull-project.test.ts b/packages/insomnia-app/app/sync/vcs/__tests__/pull-project.test.ts index f1411f118e..e9ff82e899 100644 --- a/packages/insomnia-app/app/sync/vcs/__tests__/pull-project.test.ts +++ b/packages/insomnia-app/app/sync/vcs/__tests__/pull-project.test.ts @@ -4,15 +4,16 @@ import { mocked } from 'ts-jest/utils'; import { globalBeforeEach } from '../../../__jest__/before-each'; import { DEFAULT_BRANCH_NAME } from '../../../common/constants'; import * as models from '../../../models'; +import { isRemoteSpace } from '../../../models/space'; import { Workspace } from '../../../models/workspace'; -import { projectSchema } from '../../__schemas__/type-schemas'; +import { projectWithTeamSchema } from '../../__schemas__/type-schemas'; import MemoryDriver from '../../store/drivers/memory-driver'; import { pullProject } from '../pull-project'; import { VCS } from '../vcs'; jest.mock('../vcs'); -const project = createBuilder(projectSchema).build(); +const project = createBuilder(projectWithTeamSchema).build(); const newMockedVcs = () => mocked(new VCS(new MemoryDriver()), true); @@ -39,14 +40,19 @@ describe('pullProject()', () => { expect(vcs.pull).not.toHaveBeenCalledWith([], expect.anything()); }); - it('should insert a workspace with parent', async () => { + it('should use existing space', async () => { // Arrange - const space = await models.space.create(); + const space = await models.space.create({ + name: `${project.team.name} unique`, + remoteId: project.team.id, + }); // Act - await pullProject({ vcs, project, space }); + await pullProject({ vcs, project, remoteSpaces: [space].filter(isRemoteSpace) }); // Assert + expect(space?.name).not.toBe(project.team.name); // should not rename if the space already exists + const workspaces = await models.workspace.all(); expect(workspaces).toHaveLength(1); const workspace = workspaces[0]; @@ -54,25 +60,46 @@ describe('pullProject()', () => { _id: project.rootDocumentId, name: project.name, parentId: space._id, + scope: 'collection', + })); + }); + + it('should insert a space and workspace with parent', async () => { + // Act + await pullProject({ vcs, project, remoteSpaces: [] }); + + // Assert + const space = await models.space.getByRemoteId(project.team.id); + expect(space?.name).toBe(project.team.name); + + const workspaces = await models.workspace.all(); + expect(workspaces).toHaveLength(1); + const workspace = workspaces[0]; + expect(workspace).toStrictEqual(expect.objectContaining>({ + _id: project.rootDocumentId, + name: project.name, + parentId: space?._id, + scope: 'collection', })); }); it('should update a workspace if the name or parentId is different', async () => { // Arrange await models.workspace.create({ _id: project.rootDocumentId, name: 'someName' }); - const space = await models.space.create(); // Act - await pullProject({ vcs, project, space }); + await pullProject({ vcs, project, remoteSpaces: [] }); // Assert + const space = await models.space.getByRemoteId(project.team.id); + const workspaces = await models.workspace.all(); expect(workspaces).toHaveLength(1); const workspace = workspaces[0]; expect(workspace).toStrictEqual(expect.objectContaining>({ _id: project.rootDocumentId, name: project.name, - parentId: space._id, + parentId: space?._id, })); }); }); @@ -87,23 +114,24 @@ describe('pullProject()', () => { it('should overwrite the parentId only for a workspace with the space id', async () => { // Arrange - const space = await models.space.create({ remoteId: 'abc' }); const existingWrk = await models.workspace.create({ _id: project.rootDocumentId, name: project.name }); const existingReq = await models.request.create({ parentId: existingWrk._id }); vcs.allDocuments.mockResolvedValue([existingWrk, existingReq]); // Act - await pullProject({ vcs, project, space }); + await pullProject({ vcs, project, remoteSpaces: [] }); // Assert + const space = await models.space.getByRemoteId(project.team.id); + const workspaces = await models.workspace.all(); expect(workspaces).toHaveLength(1); const workspace = workspaces[0]; expect(workspace).toStrictEqual(expect.objectContaining>({ _id: project.rootDocumentId, name: project.name, - parentId: space._id, + parentId: space?._id, })); const requests = await models.request.all(); @@ -111,7 +139,7 @@ describe('pullProject()', () => { const request = requests[0]; expect(request).toStrictEqual(existingReq); - expect(vcs.pull).toHaveBeenCalledWith([], space.remoteId); + expect(vcs.pull).toHaveBeenCalledWith([], space?.remoteId); }); }); }); diff --git a/packages/insomnia-app/app/sync/vcs/initialize-model-from.ts b/packages/insomnia-app/app/sync/vcs/initialize-model-from.ts new file mode 100644 index 0000000000..8506ee6392 --- /dev/null +++ b/packages/insomnia-app/app/sync/vcs/initialize-model-from.ts @@ -0,0 +1,25 @@ +import * as models from '../../models'; +import { RemoteSpace } from '../../models/space'; +import { Space } from '../../models/space'; +import { Workspace, WorkspaceScopeKeys } from '../../models/workspace'; +import { Team } from '../types'; +import { Project } from '../types'; + +export const initializeSpaceFromTeam = (team: Team) => models.initModel( + models.space.type, + { + _id: `${models.space.prefix}_${team.id}`, + remoteId: team.id, + name: team.name, + } +); + +export const initializeWorkspaceFromProject = (project: Project, space: Space) => models.initModel( + models.workspace.type, + { + _id: project.rootDocumentId, + name: project.name, + parentId: space._id, + scope: WorkspaceScopeKeys.collection, + } +); \ No newline at end of file diff --git a/packages/insomnia-app/app/sync/vcs/normalize-project-team.ts b/packages/insomnia-app/app/sync/vcs/normalize-project-team.ts new file mode 100644 index 0000000000..e22039385b --- /dev/null +++ b/packages/insomnia-app/app/sync/vcs/normalize-project-team.ts @@ -0,0 +1,17 @@ +import { Project, Team } from '../types'; + +export interface ProjectWithTeams extends Project { + teams: Team[]; +} + +export interface ProjectWithTeam extends Project { + team: Team; +} + +export const normalizeProjectTeam = (project: ProjectWithTeams): ProjectWithTeam => ({ + id: project.id, + name: project.name, + rootDocumentId: project.rootDocumentId, + // A project is guaranteed to exist on exactly one team + team: project.teams[0], +}); \ No newline at end of file diff --git a/packages/insomnia-app/app/sync/vcs/pull-project.ts b/packages/insomnia-app/app/sync/vcs/pull-project.ts index 97fc1e9b00..5879cfe082 100644 --- a/packages/insomnia-app/app/sync/vcs/pull-project.ts +++ b/packages/insomnia-app/app/sync/vcs/pull-project.ts @@ -1,34 +1,35 @@ import { DEFAULT_BRANCH_NAME } from '../../common/constants'; import { database } from '../../common/database'; -import * as models from '../../models'; -import { Space } from '../../models/space'; -import { isWorkspace, Workspace } from '../../models/workspace'; -import { Project } from '../types'; +import { RemoteSpace } from '../../models/space'; +import { isWorkspace } from '../../models/workspace'; +import { initializeSpaceFromTeam, initializeWorkspaceFromProject } from './initialize-model-from'; +import { ProjectWithTeam } from './normalize-project-team'; import { VCS } from './vcs'; interface Options { vcs: VCS; - project: Project; - space: Space; + project: ProjectWithTeam; + remoteSpaces: RemoteSpace[]; } -export const pullProject = async ({ vcs, project, space }: Options) => { +export const pullProject = async ({ vcs, project, remoteSpaces }: Options) => { // Set project, checkout master, and pull await vcs.setProject(project); await vcs.checkout([], DEFAULT_BRANCH_NAME); const remoteBranches = await vcs.getRemoteBranches(); const defaultBranchMissing = !remoteBranches.includes(DEFAULT_BRANCH_NAME); - const workspaceParentId = space._id; + // Find or create the remote space locally + let space = remoteSpaces.find(({ remoteId }) => remoteId === project.team.id); + if (!space) { + space = await initializeSpaceFromTeam(project.team); + await database.upsert(space); + } // The default branch does not exist, so we create it and the workspace locally if (defaultBranchMissing) { - const workspace: Workspace = await models.initModel(models.workspace.type, { - _id: project.rootDocumentId, - name: project.name, - parentId: workspaceParentId, - }); + const workspace = await initializeWorkspaceFromProject(project, space); await database.upsert(workspace); } else { await vcs.pull([], space.remoteId); // There won't be any existing docs since it's a new pull @@ -38,11 +39,13 @@ export const pullProject = async ({ vcs, project, space }: Options) => { // @ts-expect-error -- TSCONVERSION for (const doc of (await vcs.allDocuments() || [])) { if (isWorkspace(doc)) { - doc.parentId = workspaceParentId; + doc.parentId = space._id; } await database.upsert(doc); } await database.flushChanges(flushId); } + + return space; }; diff --git a/packages/insomnia-app/app/sync/vcs/vcs.ts b/packages/insomnia-app/app/sync/vcs/vcs.ts index c1b1807fd5..4bbcab8ea0 100644 --- a/packages/insomnia-app/app/sync/vcs/vcs.ts +++ b/packages/insomnia-app/app/sync/vcs/vcs.ts @@ -24,6 +24,7 @@ import type { StatusCandidate, Team, } from '../types'; +import { normalizeProjectTeam, ProjectWithTeams } from './normalize-project-team'; import * as paths from './paths'; import { compareBranches, @@ -121,10 +122,14 @@ export class VCS { return this._allProjects(); } - async remoteProjects(teamId?: string) { + async remoteProjects(teamId: string) { return this._queryProjects(teamId); } + async remoteProjectsInAnyTeam() { + return this._queryProjects(); + } + async blobFromLastSnapshot(key: string) { const branch = await this._getCurrentBranch(); const snapshot = await this._getLatestSnapshot(branch.name); @@ -1113,9 +1118,7 @@ export class VCS { return projectShareInstructions; } - async _queryProjects( - teamId?: string, - ): Promise { + async _queryProjects(teamId?: string) { const { projects } = await this._runGraphQL( ` query ($teamId: ID) { @@ -1123,6 +1126,10 @@ export class VCS { id name rootDocumentId + teams { + id + name + } } } `, @@ -1131,7 +1138,8 @@ export class VCS { }, 'projects', ); - return projects; + + return (projects as ProjectWithTeams[]).map(normalizeProjectTeam); } async _queryProject(): Promise { diff --git a/packages/insomnia-app/app/test-utils.ts b/packages/insomnia-app/app/test-utils.ts index bd7192b732..0f9af71c95 100644 --- a/packages/insomnia-app/app/test-utils.ts +++ b/packages/insomnia-app/app/test-utils.ts @@ -9,15 +9,9 @@ export const getAndClearShowPromptMockArgs = () => { export const getAndClearShowAlertMockArgs = () => { const mockFn = showAlert as jest.Mock>; - const { title, okLabel, addCancel, message, onConfirm } = mockFn.mock.calls[0][0]; + const options = mockFn.mock.calls[0][0]; mockFn.mockClear(); - return { - title, - okLabel, - addCancel, - message, - onConfirm, - }; + return options; }; export const getAndClearShowErrorMockArgs = () => { diff --git a/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx b/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx index 51a74c5e7c..d490c48b5b 100644 --- a/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx +++ b/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx @@ -1,6 +1,8 @@ import { autoBindMethodsForReact } from 'class-autobind-decorator'; import classnames from 'classnames'; import React, { Fragment, PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import * as session from '../../../account/session'; import { AUTOBIND_CFG, DEFAULT_BRANCH_NAME } from '../../../common/constants'; @@ -8,13 +10,17 @@ import { database as db } from '../../../common/database'; import { docsVersionControl } from '../../../common/documentation'; import { strings } from '../../../common/strings'; import * as models from '../../../models'; -import { Space } from '../../../models/space'; +import { isRemoteSpace, Space } from '../../../models/space'; import type { Workspace } from '../../../models/workspace'; import { WorkspaceMeta } from '../../../models/workspace-meta'; -import type { Project, Snapshot, Status, StatusCandidate } from '../../../sync/types'; +import { Snapshot, Status, StatusCandidate } from '../../../sync/types'; import { pushSnapshotOnInitialize } from '../../../sync/vcs/initialize-project'; +import { ProjectWithTeam } from '../../../sync/vcs/normalize-project-team'; import { pullProject } from '../../../sync/vcs/pull-project'; import { VCS } from '../../../sync/vcs/vcs'; +import { RootState } from '../../redux/modules'; +import { activateWorkspace } from '../../redux/modules/workspace'; +import { selectRemoteSpaces } from '../../redux/selectors'; import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../base/dropdown'; import Link from '../base/link'; import PromptButton from '../base/prompt-button'; @@ -33,7 +39,20 @@ const REFRESH_USER_ACTIVITY = 1000 * 60 * 10; // Refresh dropdown periodically const REFRESH_PERIOD = 1000 * 60 * 1; -interface Props { +type ReduxProps = ReturnType & ReturnType; + +const mapStateToProps = (state: RootState) => ({ + remoteSpaces: selectRemoteSpaces(state), +}); + +const mapDispatchToProps = dispatch => { + const bound = bindActionCreators({ activateWorkspace }, dispatch); + return { + handleActivateWorkspace: bound.activateWorkspace, + }; +}; + +interface Props extends ReduxProps { workspace: Workspace; workspaceMeta?: WorkspaceMeta; space: Space; @@ -55,11 +74,11 @@ interface State { loadingPull: boolean; loadingProjectPull: boolean; loadingPush: boolean; - remoteProjects: Project[]; + remoteProjects: ProjectWithTeam[]; } @autoBindMethodsForReact(AUTOBIND_CFG) -class SyncDropdown extends PureComponent { +class UnconnectedSyncDropdown extends PureComponent { checkInterval: NodeJS.Timeout | null = null; refreshOnNextSyncItems = false; lastUserActivity = Date.now(); @@ -85,10 +104,10 @@ class SyncDropdown extends PureComponent { } async refreshMainAttributes(extraState: Partial = {}) { - const { vcs, syncItems, workspace } = this.props; + const { vcs, syncItems, workspace, space } = this.props; - if (!vcs.hasProject()) { - const remoteProjects = await vcs.remoteProjects(); + if (!vcs.hasProject() && isRemoteSpace(space)) { + const remoteProjects = await vcs.remoteProjectsInAnyTeam(); const matchedProjects = remoteProjects.filter(p => p.rootDocumentId === workspace._id); this.setState({ remoteProjects: matchedProjects, @@ -292,13 +311,21 @@ class SyncDropdown extends PureComponent { }); } - async _handleSetProject(p: Project) { - const { vcs, space } = this.props; + async _handleSetProject(p: ProjectWithTeam) { + const { vcs, remoteSpaces, space, workspace, handleActivateWorkspace } = this.props; this.setState({ loadingProjectPull: true, }); - await pullProject({ vcs, project: p, space }); + const pulledIntoSpace = await pullProject({ vcs, project: p, remoteSpaces }); + if (pulledIntoSpace._id !== space._id) { + // If pulled into a different space, reactivate the workspace + await handleActivateWorkspace({ workspaceId: workspace._id }); + console.log('[sync] collection has been moved to the remote space to which it belongs', { + id: pulledIntoSpace._id, + name: pulledIntoSpace.name, + }); + } await this.refreshMainAttributes({ loadingProjectPull: false, @@ -583,4 +610,5 @@ class SyncDropdown extends PureComponent { } } +const SyncDropdown = connect(mapStateToProps, mapDispatchToProps)(UnconnectedSyncDropdown); export default SyncDropdown; diff --git a/packages/insomnia-app/app/ui/components/modals/request-switcher-modal.tsx b/packages/insomnia-app/app/ui/components/modals/request-switcher-modal.tsx index 335d6e3fe1..d19d3db9dd 100644 --- a/packages/insomnia-app/app/ui/components/modals/request-switcher-modal.tsx +++ b/packages/insomnia-app/app/ui/components/modals/request-switcher-modal.tsx @@ -174,7 +174,7 @@ class RequestSwitcherModal extends PureComponent { } async _activateWorkspace(workspace: Workspace) { - await this.props.handleActivateWorkspace(workspace); + await this.props.handleActivateWorkspace({ workspace }); this.modal?.hide(); } 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 index 9ce631da25..87789852a9 100644 --- a/packages/insomnia-app/app/ui/components/modals/workspace-duplicate-modal.tsx +++ b/packages/insomnia-app/app/ui/components/modals/workspace-duplicate-modal.tsx @@ -81,7 +81,7 @@ const WorkspaceDuplicateModalInternalWithRef: ForwardRefRenderFunction = ({ /> ); + const crumbs = [ + { id: 'home', node: strings.home.singular, onClick: homeCallback }, + { id: 'workspace', node: {workspace} }, + ]; + return (
Insomnia - {workspace} }]}/> + } gridCenter={ diff --git a/packages/insomnia-app/app/ui/components/wrapper-debug.tsx b/packages/insomnia-app/app/ui/components/wrapper-debug.tsx index 1795f35f38..c05510b348 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-debug.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper-debug.tsx @@ -5,6 +5,7 @@ import { AUTOBIND_CFG, GlobalActivity, SortOrder } from '../../common/constants' import { isGrpcRequest } from '../../models/grpc-request'; import { Request, RequestAuthentication, RequestBody, RequestHeader, RequestParameter } from '../../models/request'; import { Settings } from '../../models/settings'; +import { isRemoteSpace } from '../../models/space'; import { isCollection, isDesign } from '../../models/workspace'; import EnvironmentsDropdown from './dropdowns/environments-dropdown'; import SyncDropdown from './dropdowns/sync-dropdown'; @@ -66,7 +67,7 @@ class WrapperDebug extends PureComponent { let insomniaSync: ReactNode = null; - if (isLoggedIn && collection && activeSpace?.remoteId && vcs) { + if (isLoggedIn && collection && isRemoteSpace(activeSpace) && vcs) { insomniaSync = { {...props} key={props.apiSpec._id} activeSpace={activeSpace} - onSelect={() => handleActivateWorkspace(props.workspace)} + onSelect={() => handleActivateWorkspace({ workspace: props.workspace })} filter={filter} /> )); diff --git a/packages/insomnia-app/app/ui/containers/app.tsx b/packages/insomnia-app/app/ui/containers/app.tsx index 0e638b87f6..1a0527a7e7 100644 --- a/packages/insomnia-app/app/ui/containers/app.tsx +++ b/packages/insomnia-app/app/ui/containers/app.tsx @@ -1097,12 +1097,13 @@ class App extends PureComponent { } /** - * Update document.title to be "Workspace (Environment) – Request" when not home + * Update document.title to be "Space - Workspace (Environment) – Request" when not home * @private */ _updateDocumentTitle() { const { activeWorkspace, + activeSpace, activeApiSpec, activeEnvironment, activeRequest, @@ -1113,7 +1114,8 @@ class App extends PureComponent { if (activity === ACTIVITY_HOME || activity === ACTIVITY_MIGRATION) { title = getAppName(); } else if (activeWorkspace && activeApiSpec) { - title = isCollection(activeWorkspace) ? activeWorkspace.name : activeApiSpec.fileName; + title = activeSpace.name; + title += ` - ${isCollection(activeWorkspace) ? activeWorkspace.name : activeApiSpec.fileName}`; if (activeEnvironment) { title += ` (${activeEnvironment.name})`; diff --git a/packages/insomnia-app/app/ui/hooks/__tests__/space.test.ts b/packages/insomnia-app/app/ui/hooks/__tests__/space.test.ts index 5c59597df5..e6a272fd88 100644 --- a/packages/insomnia-app/app/ui/hooks/__tests__/space.test.ts +++ b/packages/insomnia-app/app/ui/hooks/__tests__/space.test.ts @@ -1,37 +1,42 @@ + import { act, renderHook } from '@testing-library/react-hooks'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; import { mocked } from 'ts-jest/utils'; import { globalBeforeEach } from '../../../__jest__/before-each'; -import { isLoggedIn as _isLoggedIn } from '../../../account/session'; +import { reduxStateForTest } from '../../../__jest__/redux-state-for-test'; +import { withReduxStore } from '../../../__jest__/with-redux-store'; import * as models from '../../../models'; import { BASE_SPACE_ID, Space } from '../../../models/space'; import MemoryDriver from '../../../sync/store/drivers/memory-driver'; import { VCS } from '../../../sync/vcs/vcs'; +import { RootState } from '../../redux/modules'; import { useRemoteSpaces } from '../space'; -jest.mock('../../../account/session', () => ({ - isLoggedIn: jest.fn(), -})); - jest.mock('../../../sync/vcs/vcs'); -const isLoggedIn = mocked(_isLoggedIn); +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); const newMockedVcs = () => mocked(new VCS(new MemoryDriver()), true); describe('useRemoteSpaces', () => { beforeEach(globalBeforeEach); - it('should not load teams if VCS is not set', () => { - const { result } = renderHook(() => useRemoteSpaces()); + it('should not load teams if VCS is not set', async () => { + const store = mockStore(await reduxStateForTest()); + + const { result } = renderHook(() => useRemoteSpaces(), { wrapper: withReduxStore(store) }); expect(result.current.loading).toBe(false); }); it('should not load teams if not signed in', async () => { + const store = mockStore(await reduxStateForTest({ isLoggedIn: false })); + const vcs = newMockedVcs(); - isLoggedIn.mockReturnValue(false); - const { result } = renderHook(() => useRemoteSpaces(vcs)); + const { result } = renderHook(() => useRemoteSpaces(vcs), { wrapper: withReduxStore(store) }); result.current.refresh(); expect(vcs.teams).not.toHaveBeenCalled(); @@ -40,7 +45,7 @@ describe('useRemoteSpaces', () => { }); it('should load teams each time VCS changes', async () => { - isLoggedIn.mockReturnValue(true); + const store = mockStore(await reduxStateForTest({ isLoggedIn: true })); const vcs1 = newMockedVcs(); const vcs2 = newMockedVcs(); @@ -49,7 +54,7 @@ describe('useRemoteSpaces', () => { vcs1.teams.mockResolvedValue([team1]); vcs2.teams.mockResolvedValue([team2]); - const { result, rerender, waitFor } = renderHook(prop => useRemoteSpaces(prop), { initialProps: vcs1 }); + const { result, rerender, waitFor } = renderHook((prop: VCS) => useRemoteSpaces(prop), { initialProps: vcs1, wrapper: withReduxStore(store) }); // Wait for effect await waitFor(() => result.current.loading === true); @@ -83,12 +88,12 @@ describe('useRemoteSpaces', () => { }); it('should load teams on refresh', async () => { - isLoggedIn.mockReturnValue(true); + const store = mockStore(await reduxStateForTest({ isLoggedIn: true })); const vcs = newMockedVcs(); vcs.teams.mockResolvedValue([]); - const { result, waitFor } = renderHook(() => useRemoteSpaces(vcs)); + const { result, waitFor } = renderHook(() => useRemoteSpaces(vcs), { wrapper: withReduxStore(store) }); // Wait for effect await waitFor(() => result.current.loading === true); diff --git a/packages/insomnia-app/app/ui/hooks/space.ts b/packages/insomnia-app/app/ui/hooks/space.ts index 55d3b8fa1d..2efb1ced66 100644 --- a/packages/insomnia-app/app/ui/hooks/space.ts +++ b/packages/insomnia-app/app/ui/hooks/space.ts @@ -1,34 +1,28 @@ import { useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; -import { isLoggedIn } from '../../account/session'; import { database } from '../../common/database'; -import * as models from '../../models'; -import { Space } from '../../models/space'; +import { initializeSpaceFromTeam } from '../../sync/vcs/initialize-model-from'; import { VCS } from '../../sync/vcs/vcs'; +import { selectIsLoggedIn } from '../redux/selectors'; import { useSafeState } from './use-safe-state'; export const useRemoteSpaces = (vcs?: VCS) => { const [loading, setLoading] = useSafeState(false); + const isLoggedIn = useSelector(selectIsLoggedIn); const refresh = useCallback(async () => { - if (vcs && isLoggedIn()) { + if (vcs && isLoggedIn) { setLoading(true); const teams = await vcs.teams(); - const spaces = await Promise.all(teams.map(team => models.initModel( - models.space.type, - { - _id: `${models.space.prefix}_${team.id}`, - remoteId: team.id, - name: team.name, - }, - ))); + const spaces = await Promise.all(teams.map(initializeSpaceFromTeam)); await database.batchModifyDocs({ upsert: spaces }); setLoading(false); } - }, [vcs, setLoading]); + }, [vcs, setLoading, isLoggedIn]); // If the refresh callback changes, refresh useEffect(() => { diff --git a/packages/insomnia-app/app/ui/hooks/workspace.ts b/packages/insomnia-app/app/ui/hooks/workspace.ts index 22c282bf18..dd377b465f 100644 --- a/packages/insomnia-app/app/ui/hooks/workspace.ts +++ b/packages/insomnia-app/app/ui/hooks/workspace.ts @@ -1,18 +1,19 @@ import { Reducer, useCallback, useEffect, useMemo, useReducer } from 'react'; import { useSelector } from 'react-redux'; -import { isLoggedIn } from '../../account/session'; +import { isRemoteSpace } from '../../models/space'; import { Project } from '../../sync/types'; +import { ProjectWithTeam } from '../../sync/vcs/normalize-project-team'; import { pullProject } from '../../sync/vcs/pull-project'; import { VCS } from '../../sync/vcs/vcs'; import { showAlert } from '../components/modals'; -import { selectActiveSpace, selectAllWorkspaces } from '../redux/selectors'; +import { selectActiveSpace, selectAllWorkspaces, selectIsLoggedIn, selectRemoteSpaces } from '../redux/selectors'; import { useSafeReducerDispatch } from './use-safe-reducer-dispatch'; interface State { loading: boolean; localProjects: Project[]; - remoteProjects: Project[]; + remoteProjects: ProjectWithTeam[]; pullingProjects: Record; } @@ -48,7 +49,9 @@ export const useRemoteWorkspaces = (vcs?: VCS) => { // Fetch from redux const workspaces = useSelector(selectAllWorkspaces); const activeSpace = useSelector(selectActiveSpace); - const spaceRemoteId = activeSpace?.remoteId || undefined; + const remoteSpaces = useSelector(selectRemoteSpaces); + const isLoggedIn = useSelector(selectIsLoggedIn); + // Local state const [{ loading, localProjects, remoteProjects, pullingProjects }, _dispatch] = useReducer(reducer, initialState); @@ -56,15 +59,15 @@ export const useRemoteWorkspaces = (vcs?: VCS) => { // Refresh remote spaces const refresh = useCallback(async () => { - if (!vcs || !isLoggedIn()) { + if (!vcs || !isLoggedIn || !isRemoteSpace(activeSpace)) { return; } dispatch({ type: 'loadProjects' }); - const remote = await vcs.remoteProjects(spaceRemoteId); + const remote = await vcs.remoteProjects(activeSpace.remoteId); const local = await vcs.localProjects(); dispatch({ type: 'saveProjects', local, remote }); - }, [dispatch, spaceRemoteId, vcs]); + }, [vcs, isLoggedIn, activeSpace, dispatch]); // Find remote spaces that haven't been pulled const missingProjects = useMemo(() => remoteProjects.filter(({ id, rootDocumentId }) => { @@ -77,7 +80,7 @@ export const useRemoteWorkspaces = (vcs?: VCS) => { }), [localProjects, remoteProjects, workspaces]); // Pull a remote space - const pull = useCallback(async (project: Project) => { + const pull = useCallback(async (project: ProjectWithTeam) => { if (!vcs) { throw new Error('VCS is not defined'); } @@ -90,7 +93,7 @@ export const useRemoteWorkspaces = (vcs?: VCS) => { // Remove all projects for workspace first await newVCS.removeProjectsForRoot(project.rootDocumentId); - await pullProject({ vcs: newVCS, project, space: activeSpace }); + await pullProject({ vcs: newVCS, project, remoteSpaces }); await refresh(); } catch (err) { @@ -101,7 +104,7 @@ export const useRemoteWorkspaces = (vcs?: VCS) => { } finally { dispatch({ type: 'stopPullingProject', projectId: project.id }); } - }, [vcs, refresh, activeSpace, dispatch]); + }, [vcs, refresh, remoteSpaces, dispatch]); // If the refresh callback changes, refresh useEffect(() => { diff --git a/packages/insomnia-app/app/ui/redux/modules/__tests__/git.test.tsx b/packages/insomnia-app/app/ui/redux/modules/__tests__/git.test.tsx index ef8522d0f5..a53877e978 100644 --- a/packages/insomnia-app/app/ui/redux/modules/__tests__/git.test.tsx +++ b/packages/insomnia-app/app/ui/redux/modules/__tests__/git.test.tsx @@ -94,7 +94,7 @@ describe('git', () => { 'No document found in the repository for import. Would you like to create a new one?', ); // Confirm - await alertArgs.onConfirm(); + await alertArgs.onConfirm?.(); // Should show new design doc prompt const promptArgs = getAndClearShowPromptMockArgs(); expect(promptArgs.title).toBe('Create New Design Document'); @@ -289,7 +289,7 @@ describe('git', () => { it? , ); - await alertArgs.onConfirm(); + await alertArgs.onConfirm?.(); }; it('should retry clone with .git suffix and succeed', async () => { 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 f485696861..c68ee6993f 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 @@ -115,12 +115,43 @@ describe('workspace', () => { }); describe('activateWorkspace', () => { + it('should do nothing if workspace cannot be found', async () => { + const store = mockStore(await reduxStateForTest({ activeSpaceId: 'abc', activeWorkspaceId: 'def' })); + + await store.dispatch(activateWorkspace({ workspaceId: 'DOES_NOT_EXIST' })); + + expect(store.getActions()).toEqual([]); + }); + + it('should activate space and workspace and activity using workspaceId', 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' })); + + await store.dispatch(activateWorkspace({ workspaceId: workspace._id })); + + 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 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' })); - await store.dispatch(activateWorkspace(workspace)); + await store.dispatch(activateWorkspace({ workspace })); expect(store.getActions()).toEqual([ { @@ -143,7 +174,7 @@ describe('workspace', () => { const workspace = await models.workspace.create({ scope: 'design', parentId: space._id }); const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id })); - await store.dispatch(activateWorkspace(workspace)); + await store.dispatch(activateWorkspace({ workspace })); expect(store.getActions()).toEqual([ { @@ -166,7 +197,7 @@ describe('workspace', () => { 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)); + await store.dispatch(activateWorkspace({ workspace })); expect(store.getActions()).toEqual([ { @@ -185,7 +216,7 @@ describe('workspace', () => { 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)); + await store.dispatch(activateWorkspace({ workspace })); expect(store.getActions()).toEqual([ { @@ -204,7 +235,7 @@ describe('workspace', () => { const workspace = await models.workspace.create({ scope: 'collection', parentId: space._id }); const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id })); - await store.dispatch(activateWorkspace(workspace)); + await store.dispatch(activateWorkspace({ workspace })); expect(store.getActions()).toEqual([ { @@ -229,7 +260,7 @@ describe('workspace', () => { await models.workspaceMeta.updateByParentId(workspace._id, { activeActivity: ACTIVITY_UNIT_TEST }); const store = mockStore(await reduxStateForTest({ activeSpaceId: space._id, activeWorkspaceId: workspace._id })); - await store.dispatch(activateWorkspace(workspace)); + await store.dispatch(activateWorkspace({ workspace })); expect(store.getActions()).toEqual([ { diff --git a/packages/insomnia-app/app/ui/redux/modules/workspace.ts b/packages/insomnia-app/app/ui/redux/modules/workspace.ts index aecbcbcb86..dff1ea5d50 100644 --- a/packages/insomnia-app/app/ui/redux/modules/workspace.ts +++ b/packages/insomnia-app/app/ui/redux/modules/workspace.ts @@ -1,4 +1,5 @@ import { Dispatch } from 'redux'; +import { RequireExactlyOne } from 'type-fest'; import { trackEvent, trackSegmentEvent } from '../../../common/analytics'; import { ACTIVITY_DEBUG, ACTIVITY_SPEC, GlobalActivity, isCollectionActivity, isDesignActivity } from '../../../common/constants'; @@ -6,7 +7,7 @@ import { database } from '../../../common/database'; import * as models from '../../../models'; import { isCollection, isDesign, Workspace, WorkspaceScope } from '../../../models/workspace'; import { showPrompt } from '../../components/modals'; -import { selectActiveActivity, selectActiveSpace } from '../selectors'; +import { selectActiveActivity, selectActiveSpace, selectAllWorkspaces } from '../selectors'; import { RootState } from '.'; import { setActiveActivity, setActiveSpace, setActiveWorkspace } from './global'; @@ -31,7 +32,7 @@ const actuallyCreate = (patch: Partial, onCreate?: OnWorkspaceCreateC } trackEvent('Workspace', 'Create'); - await dispatch(activateWorkspace(workspace)); + await dispatch(activateWorkspace({ workspace })); }; }; @@ -71,8 +72,17 @@ export const createWorkspace = ({ scope, onCreate }: { }; }; -export const activateWorkspace = (workspace: Workspace) => { +export const activateWorkspace = ({ workspace, workspaceId }: RequireExactlyOne<{workspace: Workspace, workspaceId: string}>) => { return async (dispatch: Dispatch, getState: () => RootState) => { + // If we have no workspace but we do have an id, search for it + if (!workspace && workspaceId) { + workspace = selectAllWorkspaces(getState()).find(({ _id }) => _id === workspaceId); + } + + // If we still have no workspace, exit + if (!workspace) { + return; + } const activeActivity = selectActiveActivity(getState()) || undefined; @@ -96,5 +106,7 @@ export const activateWorkspace = (workspace: Workspace) => { const nextActivity = cachedActivity as GlobalActivity || (isDesign(workspace) ? ACTIVITY_SPEC : ACTIVITY_DEBUG); dispatch(setActiveActivity(nextActivity)); } + + // TODO: dispatch one action to activate the space, workspace and activity in one go to avoid jumps in the UI }; }; \ 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 5876c16e56..9dccb95b57 100644 --- a/packages/insomnia-app/app/ui/redux/selectors.ts +++ b/packages/insomnia-app/app/ui/redux/selectors.ts @@ -7,7 +7,7 @@ import { BaseModel } from '../../models'; import { getStatusCandidates } from '../../models/helpers/get-status-candidates'; import { isRequest, Request } from '../../models/request'; import { isRequestGroup, RequestGroup } from '../../models/request-group'; -import { BASE_SPACE_ID } from '../../models/space'; +import { BASE_SPACE_ID, isRemoteSpace } from '../../models/space'; import { UnitTestResult } from '../../models/unit-test-result'; import { RootState } from './modules'; @@ -76,6 +76,11 @@ export const selectSpaces = createSelector( entities => entities.spaces, ); +export const selectRemoteSpaces = createSelector( + selectSpaces, + spaces => spaces.filter(isRemoteSpace), +); + export const selectActiveSpace = createSelector( selectEntities, (state: RootState) => state.global.activeSpaceId,