Pull a project into the correct space (#3873)

* use selector and improve types

* Loading remote projects now requires a team id; it is no longer optional

* activate with workspace or workspace id

* extract helper

* normalize graphql return type for a remote project to include the team

* pull into the space as defined by the project team id, not by the active space. If the space doesn't exist, create it

* use helper

* make testing easier by putting the active space name as the first breadcrumb

* fix tests

* add the active space into the title

* refactor and add tests

* remove todo
This commit is contained in:
Opender Singh authored and GitHub committed 2021-08-05 23:30:31 +00:00
1 parent e683a02b16
commit be2c347cf1
24 files changed
+363 -116

No files matched your search

@@ -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<RootState, {}>) =>
class ReduxWrapper extends React.Component {
render() {
const { children } = this.props;
return <Provider store={store}>{children}</Provider>;
}
};
+18 -7
View File
@@ -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<BaseModel, 'type'>): 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<Space> {
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<Space>(type, { _id });
}
export function getByRemoteId(remoteId: string) {
return db.getWhere<Space>(type, { remoteId });
}
export function remove(obj: Space) {
return db.remove(obj);
}
@@ -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<Project> = {
id: () => 'id',
@@ -9,6 +10,16 @@ export const projectSchema: Schema<Project> = {
name: () => 'name',
};
export const teamSchema: Schema<Team> = {
id: () => 'teamId',
name: () => 'teamName',
};
export const projectWithTeamSchema: Schema<ProjectWithTeam> = {
...projectSchema,
team: () => createBuilder(teamSchema).build(),
};
export const branchSchema: Schema<Branch> = {
created: () => new Date(0),
modified: () => new Date(0),
@@ -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<Partial<Space>>({
_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<Partial<Workspace>>({
_id: project.rootDocumentId,
name: project.name,
parentId: space._id,
scope: WorkspaceScopeKeys.collection,
});
});
});
@@ -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<Partial<Workspace>>({
_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<Partial<Workspace>>({
_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<Partial<Workspace>>({
_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);
});
});
});
@@ -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<RemoteSpace>(
models.space.type,
{
_id: `${models.space.prefix}_${team.id}`,
remoteId: team.id,
name: team.name,
}
);
export const initializeWorkspaceFromProject = (project: Project, space: Space) => models.initModel<Workspace>(
models.workspace.type,
{
_id: project.rootDocumentId,
name: project.name,
parentId: space._id,
scope: WorkspaceScopeKeys.collection,
}
);
@@ -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],
});
@@ -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;
};
+13 -5
View File
@@ -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<Project[]> {
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<Project | null> {
+2 -8
View File
@@ -9,15 +9,9 @@ export const getAndClearShowPromptMockArgs = () => {
export const getAndClearShowAlertMockArgs = () => {
const mockFn = showAlert as jest.Mock<typeof showAlert, Parameters<typeof showAlert>>;
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 = () => {
@@ -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<typeof mapStateToProps> & ReturnType<typeof mapDispatchToProps>;
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<Props, State> {
class UnconnectedSyncDropdown extends PureComponent<Props, State> {
checkInterval: NodeJS.Timeout | null = null;
refreshOnNextSyncItems = false;
lastUserActivity = Date.now();
@@ -85,10 +104,10 @@ class SyncDropdown extends PureComponent<Props, State> {
}
async refreshMainAttributes(extraState: Partial<State> = {}) {
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<Props, State> {
});
}
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<Props, State> {
}
}
const SyncDropdown = connect(mapStateToProps, mapDispatchToProps)(UnconnectedSyncDropdown);
export default SyncDropdown;
@@ -174,7 +174,7 @@ class RequestSwitcherModal extends PureComponent<Props, State> {
}
async _activateWorkspace(workspace: Workspace) {
await this.props.handleActivateWorkspace(workspace);
await this.props.handleActivateWorkspace({ workspace });
this.modal?.hide();
}
@@ -81,7 +81,7 @@ const WorkspaceDuplicateModalInternalWithRef: ForwardRefRenderFunction<Modal, In
await initializeLocalProjectAndMarkForSync({ vcs: vcs.newInstance(), workspace: newWorkspace });
}
dispatch(activateWorkspace(newWorkspace));
dispatch(activateWorkspace({ workspace: newWorkspace }));
hide();
onDone?.();
}, [dispatch, hide, isLoggedIn, onDone, spaces, vcs, workspace]);
@@ -54,15 +54,18 @@ const WorkspacePageHeader: FunctionComponent<Props> = ({
/>
);
const crumbs = [
{ id: 'home', node: strings.home.singular, onClick: homeCallback },
{ id: 'workspace', node: <Fragment key="workspace-dd">{workspace}</Fragment> },
];
return (
<Header
className="app-header theme--app-header"
gridLeft={
<Fragment>
<img src={coreLogo} alt="Insomnia" width="24" height="24" />
<Breadcrumb
crumbs={[{ id: 'home', node: strings.home.singular, onClick: homeCallback },
{ id: 'workspace', node: <Fragment key="workspace-dd">{workspace}</Fragment> }]}/>
<Breadcrumb crumbs={crumbs}/>
</Fragment>
}
gridCenter={
@@ -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<Props> {
let insomniaSync: ReactNode = null;
if (isLoggedIn && collection && activeSpace?.remoteId && vcs) {
if (isLoggedIn && collection && isRemoteSpace(activeSpace) && vcs) {
insomniaSync = <SyncDropdown
workspace={activeWorkspace}
workspaceMeta={activeWorkspaceMeta}
@@ -341,7 +341,7 @@ class WrapperHome extends PureComponent<Props, State> {
{...props}
key={props.apiSpec._id}
activeSpace={activeSpace}
onSelect={() => handleActivateWorkspace(props.workspace)}
onSelect={() => handleActivateWorkspace({ workspace: props.workspace })}
filter={filter}
/>
));
@@ -1097,12 +1097,13 @@ class App extends PureComponent<AppProps, State> {
}
/**
* 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<AppProps, State> {
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})`;
@@ -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<RootState>(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);
+7 -13
View File
@@ -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<Space>(
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(() => {
+13 -10
View File
@@ -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<string, boolean>;
}
@@ -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(() => {
@@ -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?
</Fragment>,
);
await alertArgs.onConfirm();
await alertArgs.onConfirm?.();
};
it('should retry clone with .git suffix and succeed', async () => {
@@ -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([
{
@@ -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<Workspace>, 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
};
};
@@ -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,