insomnia/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx
Opender Singh 7ffc391428
add eslint rules for semi colons (#3989)
* add rules for semi colons

* run lint fix

* remove invalid eslint disable
2021-09-01 10:50:26 -04:00

628 lines
19 KiB
TypeScript

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';
import { database as db } from '../../../common/database';
import { docsVersionControl } from '../../../common/documentation';
import { strings } from '../../../common/strings';
import * as models from '../../../models';
import { isRemoteProject, Project } from '../../../models/project';
import type { Workspace } from '../../../models/workspace';
import { WorkspaceMeta } from '../../../models/workspace-meta';
import { Snapshot, Status, StatusCandidate } from '../../../sync/types';
import { pushSnapshotOnInitialize } from '../../../sync/vcs/initialize-backend-project';
import { logCollectionMovedToProject } from '../../../sync/vcs/migrate-collections';
import { BackendProjectWithTeam } from '../../../sync/vcs/normalize-backend-project-team';
import { pullBackendProject } from '../../../sync/vcs/pull-backend-project';
import { interceptAccessError } from '../../../sync/vcs/util';
import { VCS } from '../../../sync/vcs/vcs';
import { RootState } from '../../redux/modules';
import { activateWorkspace } from '../../redux/modules/workspace';
import { selectRemoteProjects } from '../../redux/selectors';
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../base/dropdown';
import Link from '../base/link';
import PromptButton from '../base/prompt-button';
import HelpTooltip from '../help-tooltip';
import { showAlert, showModal } from '../modals';
import ErrorModal from '../modals/error-modal';
import LoginModal from '../modals/login-modal';
import SyncBranchesModal from '../modals/sync-branches-modal';
import SyncDeleteModal from '../modals/sync-delete-modal';
import SyncHistoryModal from '../modals/sync-history-modal';
import SyncStagingModal from '../modals/sync-staging-modal';
import Tooltip from '../tooltip';
// Stop refreshing if user hasn't been active in this long
const REFRESH_USER_ACTIVITY = 1000 * 60 * 10;
// Refresh dropdown periodically
const REFRESH_PERIOD = 1000 * 60 * 1;
type ReduxProps = ReturnType<typeof mapStateToProps> & ReturnType<typeof mapDispatchToProps>;
const mapStateToProps = (state: RootState) => ({
remoteProjects: selectRemoteProjects(state),
});
const mapDispatchToProps = dispatch => {
const bound = bindActionCreators({ activateWorkspace }, dispatch);
return {
handleActivateWorkspace: bound.activateWorkspace,
};
};
interface Props extends ReduxProps {
workspace: Workspace;
workspaceMeta?: WorkspaceMeta;
project: Project;
vcs: VCS;
syncItems: StatusCandidate[];
className?: string;
}
interface State {
currentBranch: string;
localBranches: string[];
compare: {
ahead: number;
behind: number;
};
status: Status;
initializing: boolean;
historyCount: number;
loadingPull: boolean;
loadingProjectPull: boolean;
loadingPush: boolean;
remoteBackendProjects: BackendProjectWithTeam[];
}
@autoBindMethodsForReact(AUTOBIND_CFG)
class UnconnectedSyncDropdown extends PureComponent<Props, State> {
checkInterval: NodeJS.Timeout | null = null;
refreshOnNextSyncItems = false;
lastUserActivity = Date.now();
state: State = {
localBranches: [],
currentBranch: '',
compare: {
ahead: 0,
behind: 0,
},
historyCount: 0,
initializing: true,
loadingPull: false,
loadingPush: false,
loadingProjectPull: false,
status: {
key: 'n/a',
stage: {},
unstaged: {},
},
remoteBackendProjects: [],
};
async refreshMainAttributes(extraState: Partial<State> = {}) {
const { vcs, syncItems, workspace, project } = this.props;
if (!vcs.hasBackendProject() && isRemoteProject(project)) {
const remoteBackendProjects = await vcs.remoteBackendProjectsInAnyTeam();
const matchedBackendProjects = remoteBackendProjects.filter(p => p.rootDocumentId === workspace._id);
this.setState({
remoteBackendProjects: matchedBackendProjects,
});
return;
}
const localBranches = (await vcs.getBranches()).sort();
const currentBranch = await vcs.getBranch();
const historyCount = await vcs.getHistoryCount();
const status = await vcs.status(syncItems, {});
const newState: Partial<State> = {
status,
historyCount,
localBranches,
currentBranch,
...extraState,
};
// Do the remote stuff
if (session.isLoggedIn()) {
try {
newState.compare = await vcs.compareRemoteBranch();
} catch (err) {
console.log('Failed to compare remote branches', err.message);
}
}
this.setState(prevState => ({ ...prevState, ...newState }));
}
async componentDidMount() {
this.setState({
initializing: true,
});
const { vcs, workspace, workspaceMeta, project } = this.props;
try {
await pushSnapshotOnInitialize({ vcs, workspace, workspaceMeta, project });
await this.refreshMainAttributes();
} catch (err) {
console.log('[sync_menu] Error refreshing sync state', err);
} finally {
this.setState({
initializing: false,
});
}
// Refresh but only if the user has been active in the last n minutes
this.checkInterval = setInterval(async () => {
if (Date.now() - this.lastUserActivity < REFRESH_USER_ACTIVITY) {
await this.refreshMainAttributes();
}
}, REFRESH_PERIOD);
document.addEventListener('mousemove', this._handleUserActivity);
}
componentWillUnmount() {
if (this.checkInterval !== null) {
clearInterval(this.checkInterval);
}
document.removeEventListener('mousemove', this._handleUserActivity);
}
componentDidUpdate(prevProps: Props) {
const { vcs, syncItems } = this.props;
// Update if new sync items
if (syncItems !== prevProps.syncItems) {
if (vcs.hasBackendProject()) {
vcs.status(syncItems, {}).then(status => {
this.setState({
status,
});
});
}
if (this.refreshOnNextSyncItems) {
this.refreshMainAttributes();
this.refreshOnNextSyncItems = false;
}
}
}
_handleUserActivity() {
this.lastUserActivity = Date.now();
}
_handleShowBranchesModal() {
showModal(SyncBranchesModal, {
onHide: this.refreshMainAttributes,
});
}
_handleShowStagingModal() {
showModal(SyncStagingModal, {
onSnapshot: async () => {
await this.refreshMainAttributes();
},
handlePush: async () => {
await this._handlePushChanges();
},
});
}
static _handleShowLoginModal() {
showModal(LoginModal);
}
async _handlePushChanges() {
const { vcs, project: { remoteId } } = this.props;
this.setState({
loadingPush: true,
});
try {
const branch = await vcs.getBranch();
await interceptAccessError({
callback: async () => await vcs.push(remoteId),
action: 'push',
resourceName: branch,
resourceType: 'branch',
});
} catch (err) {
showModal(ErrorModal, {
title: 'Push Error',
message: err.message,
});
}
await this.refreshMainAttributes({
loadingPush: false,
});
}
async _handlePullChanges() {
const { vcs, syncItems, project: { remoteId } } = this.props;
this.setState({
loadingPull: true,
});
try {
const branch = await vcs.getBranch();
const delta = await interceptAccessError({
callback: async () => await vcs.pull(syncItems, remoteId),
action: 'pull',
resourceName: branch,
resourceType: 'branch',
});
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
this.refreshOnNextSyncItems = true;
} catch (err) {
showModal(ErrorModal, {
title: 'Pull Error',
message: err.message,
});
}
this.setState({
loadingPull: false,
});
}
async _handleRollback(snapshot: Snapshot) {
const { vcs, syncItems } = this.props;
const delta = await vcs.rollback(snapshot.id, syncItems);
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
this.refreshOnNextSyncItems = true;
}
async _handleRevert() {
const { vcs, syncItems } = this.props;
try {
const delta = await vcs.rollbackToLatest(syncItems);
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
} catch (err) {
showModal(ErrorModal, {
title: 'Revert Error',
message: err.message,
});
}
}
_handleShowHistoryModal() {
showModal(SyncHistoryModal, {
handleRollback: this._handleRollback,
});
}
async _handleOpen() {
await this.refreshMainAttributes();
}
async _handleEnableSync() {
this.setState({
loadingProjectPull: true,
});
const { vcs, workspace } = this.props;
await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name);
await this.refreshMainAttributes({
loadingProjectPull: false,
});
}
_handleShowDeleteModal() {
showModal(SyncDeleteModal, {
onHide: this.refreshMainAttributes,
});
}
async _handleSetProject(backendProject: BackendProjectWithTeam) {
const { vcs, remoteProjects, project, workspace, handleActivateWorkspace } = this.props;
this.setState({
loadingProjectPull: true,
});
const pulledIntoProject = await pullBackendProject({ vcs, backendProject, remoteProjects });
if (pulledIntoProject._id !== project._id) {
// If pulled into a different project, reactivate the workspace
await handleActivateWorkspace({ workspaceId: workspace._id });
logCollectionMovedToProject(workspace, pulledIntoProject);
}
await this.refreshMainAttributes({
loadingProjectPull: false,
});
}
async _handleSwitchBranch(branch: string) {
const { vcs, syncItems } = this.props;
try {
const delta = await vcs.checkout(syncItems, branch);
if (branch === DEFAULT_BRANCH_NAME) {
const { historyCount } = this.state;
const defaultBranchHistoryCount = await vcs.getHistoryCount(DEFAULT_BRANCH_NAME);
// If the default branch has no snapshots, but the current branch does
// It will result in the workspace getting deleted
// So we filter out the workspace from the delta to prevent this
if (!defaultBranchHistoryCount && historyCount) {
delta.remove = delta.remove.filter(e => e?.type !== models.workspace.type);
}
}
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
} catch (err) {
showAlert({
title: 'Branch Switch Error',
message: err.message,
});
}
// We can't refresh now because we won't yet have the new syncItems
this.refreshOnNextSyncItems = true;
// Still need to do this in case sync items don't change
this.setState({
currentBranch: branch,
});
}
renderBranch(branch: string) {
const { currentBranch } = this.state;
const icon =
branch === currentBranch ? <i className="fa fa-tag" /> : <i className="fa fa-empty" />;
const isCurrentBranch = branch === currentBranch;
return (
// @ts-expect-error -- TSCONVERSION
<DropdownItem
key={branch}
onClick={isCurrentBranch ? null : () => this._handleSwitchBranch(branch)}
className={classnames({
bold: isCurrentBranch,
})}
title={isCurrentBranch ? null : `Switch to "${branch}"`}
>
{icon}
{branch}
</DropdownItem>
);
}
renderButton() {
const {
currentBranch,
compare: { ahead, behind },
initializing,
status,
loadingPull,
loadingPush,
} = this.state;
const canPush = ahead > 0;
const canPull = behind > 0;
const canCreateSnapshot =
Object.keys(status.stage).length > 0 || Object.keys(status.unstaged).length > 0;
const loadIcon = <i className="fa fa-spin fa-refresh fa--fixed-width" />;
const pullToolTipMsg = canPull
? `There ${behind === 1 ? 'is' : 'are'} ${behind} snapshot${behind === 1 ? '' : 's'} to pull`
: 'No changes to pull';
const pushToolTipMsg = canPush
? `There ${ahead === 1 ? 'is' : 'are'} ${ahead} snapshot${ahead === 1 ? '' : 's'} to push`
: 'No changes to push';
const snapshotToolTipMsg = canCreateSnapshot ? 'Local changes made' : 'No local changes made';
if (currentBranch === null) {
return <Fragment>Sync</Fragment>;
}
return (
<DropdownButton
className="btn--clicky-small btn-sync btn-utility wide text-left overflow-hidden row-spaced"
disabled={initializing}
>
<div className="ellipsis">
<i className="fa fa-code-fork space-right" />{' '}
{initializing ? 'Initializing...' : currentBranch}
</div>
<div className="space-left">
<Tooltip message={snapshotToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cube fa--fixed-width', {
'super-duper-faint': !canCreateSnapshot,
})}
/>
</Tooltip>
{/* Only show cloud icons if logged in */}
{session.isLoggedIn() && (
<Fragment>
{loadingPull ? (
loadIcon
) : (
<Tooltip message={pullToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cloud-download fa--fixed-width', {
'super-duper-faint': !canPull,
})}
/>
</Tooltip>
)}
{loadingPush ? (
loadIcon
) : (
<Tooltip message={pushToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cloud-upload fa--fixed-width', {
'super-duper-faint': !canPush,
})}
/>
</Tooltip>
)}
</Fragment>
)}
</div>
</DropdownButton>
);
}
render() {
if (!session.isLoggedIn()) {
return null;
}
const { className, vcs } = this.props;
const {
localBranches,
currentBranch,
status,
historyCount,
loadingPull,
loadingPush,
loadingProjectPull,
remoteBackendProjects,
compare: { ahead, behind },
} = this.state;
const canCreateSnapshot =
Object.keys(status.stage).length > 0 || Object.keys(status.unstaged).length > 0;
const visibleBranches = localBranches.filter(b => !b.match(/\.hidden$/));
const syncMenuHeader = (
<DropdownDivider>
Insomnia Sync{' '}
<HelpTooltip>
Sync and collaborate on workspaces{' '}
<Link href={docsVersionControl}>
<span className="no-wrap">
<br />
Documentation <i className="fa fa-external-link" />
</span>
</Link>
</HelpTooltip>
</DropdownDivider>
);
if (loadingProjectPull) {
return (
<div className={className}>
<button className="btn btn--compact wide">
<i className="fa fa-refresh fa-spin" /> Initializing
</button>
</div>
);
}
if (!vcs.hasBackendProject()) {
return (
<div className={className}>
<Dropdown className="wide tall" onOpen={this._handleOpen}>
<DropdownButton className="btn btn--compact wide">
<i className="fa fa-code-fork " /> Setup Sync
</DropdownButton>
{syncMenuHeader}
{remoteBackendProjects.length === 0 && (
<DropdownItem onClick={this._handleEnableSync}>
<i className="fa fa-plus-circle" /> Create Locally
</DropdownItem>
)}
{remoteBackendProjects.map(p => (
<DropdownItem key={p.id} onClick={() => this._handleSetProject(p)}>
<i className="fa fa-cloud-download" /> Pull <strong>{p.name}</strong>
</DropdownItem>
))}
</Dropdown>
</div>
);
}
return (
<div className={className}>
<Dropdown className="wide tall" onOpen={this._handleOpen}>
{this.renderButton()}
{syncMenuHeader}
{!session.isLoggedIn() && (
<DropdownItem onClick={SyncDropdown._handleShowLoginModal}>
<i className="fa fa-sign-in" /> Log In
</DropdownItem>
)}
<DropdownItem onClick={this._handleShowBranchesModal}>
<i className="fa fa-code-fork" />
Branches
</DropdownItem>
<DropdownItem onClick={this._handleShowDeleteModal} disabled={historyCount === 0}>
<i className="fa fa-remove" />
Delete {strings.collection.singular}
</DropdownItem>
<DropdownDivider>Local Branches</DropdownDivider>
{visibleBranches.map(this.renderBranch)}
<DropdownDivider>{currentBranch}</DropdownDivider>
<DropdownItem onClick={this._handleShowHistoryModal} disabled={historyCount === 0}>
<i className="fa fa-clock-o" />
History
</DropdownItem>
<DropdownItem
onClick={this._handleRevert}
buttonClass={PromptButton}
stayOpenAfterClick
disabled={!canCreateSnapshot || historyCount === 0}
>
<i className="fa fa-undo" />
Revert Changes
</DropdownItem>
<DropdownItem onClick={this._handleShowStagingModal} disabled={!canCreateSnapshot}>
<i className="fa fa-cube" />
Create Snapshot
</DropdownItem>
<DropdownItem onClick={this._handlePullChanges} disabled={behind === 0 || loadingPull}>
{loadingPull ? (
<Fragment>
<i className="fa fa-spin fa-refresh" /> Pulling Snapshots...
</Fragment>
) : (
<Fragment>
<i className="fa fa-cloud-upload" /> Pull {behind || ''} Snapshot
{behind === 1 ? '' : 's'}
</Fragment>
)}
</DropdownItem>
<DropdownItem onClick={this._handlePushChanges} disabled={ahead === 0 || loadingPush}>
{loadingPush ? (
<Fragment>
<i className="fa fa-spin fa-refresh" /> Pushing Snapshots...
</Fragment>
) : (
<Fragment>
<i className="fa fa-cloud-upload" /> Push {ahead || ''} Snapshot
{ahead === 1 ? '' : 's'}
</Fragment>
)}
</DropdownItem>
</Dropdown>
</div>
);
}
}
const SyncDropdown = connect(mapStateToProps, mapDispatchToProps)(UnconnectedSyncDropdown);
export default SyncDropdown;