Add pull dropdown to homepage (#3074)

This commit is contained in:
Opender Singh 2021-02-12 14:52:43 +13:00 committed by GitHub
parent cf24515d48
commit 8e7c85c5ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 317 additions and 181 deletions

View File

@ -86,7 +86,7 @@ export function update(
export async function updateByParentId(
workspaceId: string,
patch: Object = {},
patch: $Shape<WorkspaceMeta> = {},
): Promise<WorkspaceMeta> {
const meta = await getByParentId(workspaceId);
return db.docUpdate(meta, patch);

View File

@ -671,6 +671,7 @@ describe('VCS', () => {
]);
});
});
describe('describeChanges()', () => {
it('works with same object structure', async () => {
const a = { foo: 'bar', nested: { baz: 10 } };
@ -690,4 +691,82 @@ describe('VCS', () => {
expect(describeChanges(a, b)).toEqual([]);
});
});
describe('getHistory()', () => {
let v;
beforeEach(async () => {
v = await vcs('master');
const status1 = await v.status(
[{ key: 'foo', name: 'Foo', document: newDoc('foobar1') }],
{},
);
const stage1 = await v.stage(status1.stage, [status1.unstaged.foo]);
await v.takeSnapshot(stage1, 'Add foo');
const status2 = await v.status(
[{ key: 'bar', name: 'Bar', document: newDoc('foobar2') }],
{},
);
const stage2 = await v.stage(status2.stage, [status2.unstaged.bar]);
await v.takeSnapshot(stage2, 'Add bar');
});
it('returns all history', async () => {
// get all history
expect(await v.getHistory()).toStrictEqual([
{
author: '',
created: expect.any(Date),
description: '',
id: 'f271aed3e12317215491ca1545770bd8289948e1',
name: 'Add foo',
parent: '0000000000000000000000000000000000000000',
state: [
{
blob: 'f3827e9fdf461634c4ce528b88ced46fecf6509c',
key: 'foo',
name: 'Foo',
},
],
},
{
author: '',
created: expect.any(Date),
description: '',
id: '3acf54915cd8d34a9cd9ea4ac3a988105b483869',
name: 'Add bar',
parent: 'f271aed3e12317215491ca1545770bd8289948e1',
state: [
{
blob: 'f3827e9fdf461634c4ce528b88ced46fecf6509c',
key: 'foo',
name: 'Foo',
},
{
blob: '2d1f409ab33b0b997d6c239a7754d24875570c2d',
key: 'bar',
name: 'Bar',
},
],
},
]);
});
it('returns recent history', async () => {
const [s1, s2, ...others] = await v.getHistory();
// There should only be two items
expect(others).toHaveLength(0);
// Get the latest item
expect(await v.getHistory(1)).toStrictEqual([s2]);
// Get the last 2 items
expect(await v.getHistory(2)).toStrictEqual([s1, s2]);
// Get the last 3 items (only 2 exist)
expect(await v.getHistory(3)).toStrictEqual([s1, s2]);
});
});
});

View File

@ -379,10 +379,14 @@ export default class VCS {
return branch.snapshots.length;
}
async getHistory(): Promise<Array<Snapshot>> {
async getHistory(count: number = 0): Promise<Array<Snapshot>> {
const branch = await this._getCurrentBranch();
const snapshots = [];
for (const id of branch.snapshots) {
const total = branch.snapshots.length;
const slice = count <= 0 || count > total ? 0 : total - count;
for (const id of branch.snapshots.slice(slice)) {
const snapshot = await this._getSnapshot(id);
if (snapshot === null) {
throw new Error(`Failed to get snapshot id=${id}`);

View File

@ -0,0 +1,189 @@
// @flow
import * as React from 'react';
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import { AUTOBIND_CFG } from '../../../common/constants';
import * as session from '../../../account/session';
import VCS from '../../../sync/vcs';
import type { Project } from '../../../sync/types';
import { Dropdown, DropdownDivider, DropdownItem, Button } from 'insomnia-components';
import type { Workspace } from '../../../models/workspace';
import HelpTooltip from '../help-tooltip';
import * as models from '../../../models';
import * as db from '../../../common/database';
import { showAlert } from '../modals';
type Props = {
className?: string,
vcs?: VCS,
workspaces: Array<Workspace>,
};
type State = {
loading: boolean,
localProjects: Array<Project>,
pullingProjects: { [string]: boolean },
remoteProjects: Array<Project>,
};
@autoBindMethodsForReact(AUTOBIND_CFG)
class RemoteWorkspacesDropdown extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
loading: false,
localProjects: [],
pullingProjects: {},
remoteProjects: [],
};
}
async _refreshRemoteWorkspaces() {
const { vcs } = this.props;
if (!vcs) {
return;
}
if (!session.isLoggedIn()) {
return;
}
this.setState({ loading: true });
const remoteProjects = await vcs.remoteProjects();
const localProjects = await vcs.localProjects();
this.setState({ remoteProjects, localProjects, loading: false });
}
async _handlePullRemoteWorkspace(project: Project) {
const { vcs } = this.props;
if (!vcs) {
throw new Error('VCS is not defined');
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: true },
}));
try {
// Clone old VCS so we don't mess anything up while working on other projects
const newVCS = vcs.newInstance();
// Remove all projects for workspace first
await newVCS.removeProjectsForRoot(project.rootDocumentId);
// Set project, checkout master, and pull
const defaultBranch = 'master';
await newVCS.setProject(project);
await newVCS.checkout([], defaultBranch);
const remoteBranches = await newVCS.getRemoteBranches();
const defaultBranchMissing = !remoteBranches.includes(defaultBranch);
// 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,
});
await db.upsert(workspace);
} else {
await newVCS.pull([]); // There won't be any existing docs since it's a new pull
const flushId = await db.bufferChanges();
for (const doc of await newVCS.allDocuments()) {
await db.upsert(doc);
}
await db.flushChanges(flushId);
}
await this._refreshRemoteWorkspaces();
} catch (err) {
this._dropdown && this._dropdown.hide();
showAlert({
title: 'Pull Error',
message: `Failed to pull workspace. ${err.message}`,
});
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: false },
}));
}
componentDidUpdate(prevProps: Props) {
// Reload workspaces if we just got a new VCS instance
if (this.props.vcs && !prevProps.vcs) {
this._refreshRemoteWorkspaces();
}
}
componentDidMount() {
this._refreshRemoteWorkspaces();
}
render() {
const { className, workspaces } = this.props;
const { loading, remoteProjects, localProjects, pullingProjects } = this.state;
if (!session.isLoggedIn()) {
return null;
}
const missingRemoteProjects = remoteProjects.filter(({ id, rootDocumentId }) => {
const localProjectExists = localProjects.find(p => p.id === id);
const workspaceExists = workspaces.find(w => w._id === rootDocumentId);
// Mark as missing if:
// - the project doesn't yet exists locally
// - the project exists locally but somehow the workspace doesn't anymore
return !(workspaceExists && localProjectExists);
});
const button = (
<Button variant="contained" bg="surprise" className={className}>
Pull
<i className="fa fa-caret-down pad-left-sm" />
</Button>
);
return (
<Dropdown onOpen={this._refreshRemoteWorkspaces} renderButton={button}>
<DropdownDivider>
Remote Workspaces{' '}
<HelpTooltip>
These workspaces have been shared with you via Insomnia Sync and do not yet exist on
your machine.
</HelpTooltip>{' '}
{loading && <i className="fa fa-spin fa-refresh" />}
</DropdownDivider>
{missingRemoteProjects.length === 0 && (
<DropdownItem disabled>Nothing to pull</DropdownItem>
)}
{missingRemoteProjects.map(p => (
<DropdownItem
key={p.id}
stayOpenAfterClick
onClick={() => this._handlePullRemoteWorkspace(p)}
icon={
pullingProjects[p.id] ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-download" />
)
}>
<span>
Pull <strong>{p.name}</strong>
</span>
</DropdownItem>
))}
</Dropdown>
);
}
}
export default RemoteWorkspacesDropdown;

View File

@ -11,11 +11,10 @@ import DropdownHint from '../base/dropdown/dropdown-hint';
import SettingsModal, { TAB_INDEX_EXPORT } from '../modals/settings-modal';
import * as models from '../../../models';
import { showAlert, showError, showModal, showPrompt } from '../modals';
import { showError, showModal, showPrompt } from '../modals';
import Link from '../base/link';
import WorkspaceSettingsModal from '../modals/workspace-settings-modal';
import LoginModal from '../modals/login-modal';
import Tooltip from '../tooltip';
import KeydownBinder from '../keydown-binder';
import type { HotKeyRegistry } from '../../../common/hotkeys';
import { hotKeyRefs } from '../../../common/hotkeys';
@ -24,8 +23,6 @@ import type { Workspace } from '../../../models/workspace';
import SyncShareModal from '../modals/sync-share-modal';
import * as db from '../../../common/database';
import VCS from '../../../sync/vcs';
import HelpTooltip from '../help-tooltip';
import type { Project } from '../../../sync/types';
import PromptButton from '../base/prompt-button';
import * as session from '../../../account/session';
import type { WorkspaceAction } from '../../../plugins';
@ -52,9 +49,6 @@ type Props = {
type State = {
actionPlugins: Array<WorkspaceAction>,
loadingActions: { [string]: boolean },
localProjects: Array<Project>,
pullingProjects: { [string]: boolean },
remoteProjects: Array<Project>,
};
@autoBindMethodsForReact(AUTOBIND_CFG)
@ -64,9 +58,6 @@ class WorkspaceDropdown extends React.PureComponent<Props, State> {
state = {
actionPlugins: [],
loadingActions: {},
localProjects: [],
pullingProjects: {},
remoteProjects: [],
};
async _handlePluginClick(p: WorkspaceAction) {
@ -111,86 +102,11 @@ class WorkspaceDropdown extends React.PureComponent<Props, State> {
}
async _handleDropdownOpen() {
this._refreshRemoteWorkspaces();
// Load action plugins
const plugins = await getWorkspaceActions();
this.setState({ actionPlugins: plugins });
}
async _refreshRemoteWorkspaces() {
const { vcs } = this.props;
if (!vcs) {
return;
}
if (!session.isLoggedIn()) {
return;
}
const remoteProjects = await vcs.remoteProjects();
const localProjects = await vcs.localProjects();
this.setState({ remoteProjects, localProjects });
}
async _handlePullRemoteWorkspace(project: Project) {
const { vcs } = this.props;
if (!vcs) {
throw new Error('VCS is not defined');
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: true },
}));
try {
// Clone old VCS so we don't mess anything up while working on other projects
const newVCS = vcs.newInstance();
// Remove all projects for workspace first
await newVCS.removeProjectsForRoot(project.rootDocumentId);
// Set project, checkout master, and pull
const defaultBranch = 'master';
await newVCS.setProject(project);
await newVCS.checkout([], defaultBranch);
const remoteBranches = await newVCS.getRemoteBranches();
const defaultBranchMissing = !remoteBranches.includes(defaultBranch);
// 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,
});
await db.upsert(workspace);
} else {
await newVCS.pull([]); // There won't be any existing docs since it's a new pull
const flushId = await db.bufferChanges();
for (const doc of await newVCS.allDocuments()) {
await db.upsert(doc);
}
await db.flushChanges(flushId);
}
await this._refreshRemoteWorkspaces();
} catch (err) {
this._dropdown && this._dropdown.hide();
showAlert({
title: 'Pull Error',
message: `Failed to pull workspace. ${err.message}`,
});
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: false },
}));
}
_setDropdownRef(n: ?Dropdown) {
this._dropdown = n;
}
@ -234,17 +150,6 @@ class WorkspaceDropdown extends React.PureComponent<Props, State> {
});
}
componentDidUpdate(prevProps: Props) {
// Reload workspaces if we just got a new VCS instance
if (this.props.vcs && !prevProps.vcs) {
this._refreshRemoteWorkspaces();
}
}
componentDidMount() {
this._refreshRemoteWorkspaces();
}
render() {
const {
displayName,
@ -258,21 +163,6 @@ class WorkspaceDropdown extends React.PureComponent<Props, State> {
...other
} = this.props;
const { remoteProjects, localProjects, pullingProjects } = this.state;
const missingRemoteProjects = remoteProjects.filter(({ id, rootDocumentId }) => {
const localProjectExists = localProjects.find(p => p.id === id);
const workspaceExists = workspaces.find(w => w._id === rootDocumentId);
// Mark as missing if:
// - the project doesn't yet exists locally
// - the project exists locally but somehow the workspace doesn't anymore
return !(workspaceExists && localProjectExists);
});
const nonActiveWorkspaces = workspaces
.filter(w => w._id !== activeWorkspace._id)
.sort((w1, w2) => w1.name.localeCompare(w2.name));
const classes = classnames(className, 'wide', 'workspace-dropdown');
const { actionPlugins, loadingActions } = this.state;
@ -303,50 +193,10 @@ class WorkspaceDropdown extends React.PureComponent<Props, State> {
<i className="fa fa-globe" /> Share <strong>{activeWorkspace.name}</strong>
</DropdownItem>
<DropdownDivider>Switch Workspace</DropdownDivider>
{nonActiveWorkspaces.map(w => {
const isUnseen = !!unseenWorkspaces.find(v => v._id === w._id);
return (
<DropdownItem key={w._id} onClick={handleSetActiveWorkspace} value={w._id}>
<i className="fa fa-random" /> To <strong>{w.name}</strong>
{isUnseen && (
<Tooltip message="You haven't seen this workspace before" position="top">
<i className="width-auto fa fa-asterisk surprise" />
</Tooltip>
)}
</DropdownItem>
);
})}
<DropdownItem onClick={this._handleWorkspaceCreate}>
<i className="fa fa-empty" /> Create Workspace
</DropdownItem>
{missingRemoteProjects.length > 0 && (
<DropdownDivider>
Remote Workspaces{' '}
<HelpTooltip>
These workspaces have been shared with you via Insomnia Sync and do not yet exist on
your machine.
</HelpTooltip>
</DropdownDivider>
)}
{missingRemoteProjects.map(p => (
<DropdownItem
key={p.id}
stayOpenAfterClick
onClick={() => this._handlePullRemoteWorkspace(p)}>
{pullingProjects[p.id] ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-download" />
)}
Pull <strong>{p.name}</strong>
</DropdownItem>
))}
<DropdownDivider>
{getAppName()} v{getAppVersion()}
</DropdownDivider>

View File

@ -54,6 +54,7 @@ import {
} from '../../sync/git/git-vcs';
import { parseApiSpec } from '../../common/api-specs';
import SettingsModal from './modals/settings-modal';
import RemoteWorkspacesDropdown from './dropdowns/remote-workspaces-dropdown';
type Props = {|
wrapperProps: WrapperProps,
@ -294,7 +295,6 @@ class WrapperHome extends React.PureComponent<Props, State> {
} else {
handleSetActiveActivity(activeActivity);
}
handleSetActiveWorkspace(id);
}
@ -335,7 +335,7 @@ class WrapperHome extends React.PureComponent<Props, State> {
let log = <TimeFromNow timestamp={modifiedLocally} />;
let branch = lastActiveBranch;
if (apiSpec && lastCommitTime && apiSpec.modified > lastCommitTime) {
if (w.scope === 'designer' && lastCommitTime && apiSpec?.modified > lastCommitTime) {
// Show locally unsaved changes for spec
// NOTE: this doesn't work for non-spec workspaces
branch = lastActiveBranch + '*';
@ -349,7 +349,7 @@ class WrapperHome extends React.PureComponent<Props, State> {
branch = lastActiveBranch;
log = (
<React.Fragment>
<TimeFromNow timestamp={lastCommitTime} /> by {lastCommitAuthor}
<TimeFromNow timestamp={lastCommitTime} /> {lastCommitAuthor && `by ${lastCommitAuthor}`}
</React.Fragment>
);
}
@ -417,14 +417,16 @@ class WrapperHome extends React.PureComponent<Props, State> {
);
}
renderMenu() {
renderCreateMenu() {
const button = (
<Button variant="contained" bg="surprise" className="margin-left">
Create
<i className="fa fa-caret-down pad-left-sm" />
</Button>
);
return (
<Dropdown
renderButton={() => (
<Button variant="contained" bg="surprise" className="margin-left">
Create <i className="fa fa-caret-down pad-left-sm" />
</Button>
)}>
<Dropdown renderButton={button}>
<DropdownDivider>New</DropdownDivider>
<DropdownItem icon={<i className="fa fa-pencil" />} onClick={this._handleWorkspaceCreate}>
Blank Document
@ -448,6 +450,30 @@ class WrapperHome extends React.PureComponent<Props, State> {
);
}
renderDashboardMenu() {
const { vcs, workspaces } = this.props.wrapperProps;
return (
<div className="row row--right pad-left wide">
<div
className="form-control form-control--outlined no-margin"
style={{ maxWidth: '400px' }}>
<KeydownBinder onKeydown={this._handleKeyDown}>
<input
ref={this._setFilterInputRef}
type="text"
placeholder="Filter..."
onChange={this._handleFilterChange}
className="no-margin"
/>
<span className="fa fa-search filter-icon" />
</KeydownBinder>
</div>
{this.renderCreateMenu()}
<RemoteWorkspacesDropdown vcs={vcs} workspaces={workspaces} className="margin-left" />
</div>
);
}
render() {
const { workspaces } = this.props.wrapperProps;
const { filter } = this.state;
@ -479,21 +505,7 @@ class WrapperHome extends React.PureComponent<Props, State> {
<div className="document-listing__body pad-bottom">
<div className="row-spaced margin-top margin-bottom-sm">
<h2 className="no-margin">Dashboard</h2>
<span className="row-spaced pad-left" style={{ maxWidth: '400px' }}>
<div className="form-control form-control--outlined no-margin">
<KeydownBinder onKeydown={this._handleKeyDown}>
<input
ref={this._setFilterInputRef}
type="text"
placeholder="Filter..."
onChange={this._handleFilterChange}
className="no-margin"
/>
<span className="fa fa-search filter-icon" />
</KeydownBinder>
</div>
{this.renderMenu()}
</span>
{this.renderDashboardMenu()}
</div>
<CardContainer>{cards}</CardContainer>
{filter && cards.length === 0 && (

View File

@ -474,6 +474,8 @@ class Dropdown extends PureComponent {
}
const noResults = filter && filterItems && filterItems.length === 0;
const button = typeof renderButton === 'function' ? renderButton({ open }) : renderButton;
return (
<StyledDropdown
style={style}
@ -483,7 +485,7 @@ class Dropdown extends PureComponent {
onKeyDown={this._handleKeyDown}
tabIndex="-1"
onMouseDown={Dropdown._handleMouseDown}>
<React.Fragment key="button">{renderButton({ open })}</React.Fragment>
<React.Fragment key="button">{button}</React.Fragment>
{ReactDOM.createPortal(
<div
key="item"
@ -517,7 +519,7 @@ class Dropdown extends PureComponent {
Dropdown.propTypes = {
// Required
children: PropTypes.node.isRequired,
renderButton: PropTypes.func.isRequired,
renderButton: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
// Optional
right: PropTypes.bool,