refactor: transform sync modals to fc (#5181)

* sync modals

* second pass

* some nits

* fix types

* include indeterminate checkbox

* use previous state setter

* fix req settings modal setStates

* fc sync pull button

* previous state

* fix staging target

* sync dropdown pass

* refresh branches modal state on show

* state readability tweaks

* fix prompt button double event

* fix branch modal target

* remove extra reload in history modal

* fix history modal

* remove logs

* extract refetch

* fix delete

* delete local and remote

* format

* revert local delete change

* current target

* staging modal

* fix lint

* fix useEffect flash

* add todo note
This commit is contained in:
Jack Kavanagh 2022-10-18 11:22:18 +01:00 committed by GitHub
parent e71609df2b
commit bf040aca31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1214 additions and 1580 deletions

View File

@ -346,13 +346,13 @@ export class VCS {
throw new Error(`Failed to find snapshot by id ${snapshotId}`);
}
const potentialNewState: SnapshotState = candidates.map(candidate => ({
const currentState: SnapshotState = candidates.map(candidate => ({
key: candidate.key,
blob: hashDocument(candidate.document).hash,
name: candidate.name,
}));
const delta = stateDelta(potentialNewState, rollbackSnapshot.state);
const delta = stateDelta(currentState, rollbackSnapshot.state);
// We need to treat removals of candidates differently because they may not yet have been stored as blobs.
const remove: StatusCandidate[] = [];
@ -378,7 +378,7 @@ export class VCS {
async getHistoryCount(branchName?: string) {
const branch = branchName ? await this._getBranch(branchName) : await this._getCurrentBranch();
return branch?.snapshots.length;
return branch?.snapshots.length || 0;
}
async getHistory(count = 0) {

View File

@ -1,40 +1,25 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { HTMLAttributes, PureComponent } from 'react';
import { AUTOBIND_CFG } from '../../../common/constants';
import React, { FC, HTMLAttributes, useEffect, useRef } from 'react';
interface Props extends HTMLAttributes<HTMLInputElement> {
indeterminate: boolean;
checked: boolean;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class IndeterminateCheckbox extends PureComponent<Props> {
input: HTMLInputElement | null = null;
export const IndeterminateCheckbox: FC<Props> = ({ checked, indeterminate, ...otherProps }) => {
const checkRef = useRef<HTMLInputElement>(null);
_setRef(input: HTMLInputElement) {
this.input = input;
}
_update() {
if (this.input) {
this.input.indeterminate = this.props.indeterminate;
useEffect(() => {
if (checkRef.current) {
checkRef.current.checked = checked;
checkRef.current.indeterminate = indeterminate;
}
}
}, [checked, indeterminate]);
componentDidMount() {
this._update();
}
componentDidUpdate() {
this._update();
}
render() {
const {
indeterminate,
...otherProps
} = this.props;
return <input ref={this._setRef} type="checkbox" {...otherProps} />;
}
}
return (
<input
type="checkbox"
ref={checkRef}
{...otherProps}
/>
);
};

View File

@ -19,7 +19,7 @@ export interface ModalProps {
}
export interface ModalHandle {
show: (options?: { onHide: () => void }) => void;
show: (options?: { onHide?: () => void }) => void;
hide: () => void;
toggle: () => void;
isOpen: () => boolean;
@ -49,8 +49,12 @@ export const Modal = forwardRef<ModalHandle, ModalProps>(({
const hide = useCallback(() => {
setOpen(false);
onHideProp?.();
onHideArgument?.();
if (typeof onHideProp === 'function') {
onHideProp();
}
if (typeof onHideArgument === 'function') {
onHideArgument();
}
}, [onHideProp, onHideArgument]);
useImperativeHandle(ref, () => ({

View File

@ -46,50 +46,36 @@ export const PromptButton = <T, >({
};
}, []);
const handleConfirm = (event: MouseEvent<HTMLButtonElement>) => {
if (triggerTimeout.current !== null) {
// Clear existing timeouts
clearTimeout(triggerTimeout.current);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (state === 'default') {
// Prevent events (ex. won't close dropdown if it's in one)
event.preventDefault();
event.stopPropagation();
// Toggle the confirmation notice
setState('ask');
// Set a timeout to hide the confirmation
// using global.setTimeout to force use of the Node timeout rather than DOM timeout
triggerTimeout.current = global.setTimeout(() => {
setState('default');
}, 2000);
}
// Fire the click handler
onClick?.(event);
// Set the state to done (but delay a bit to not alarm user)
// using global.setTimeout to force use of the Node timeout rather than DOM timeout
doneTimeout.current = global.setTimeout(() => {
setState('done');
}, 100);
// Set a timeout to hide the confirmation
// using global.setTimeout to force use of the Node timeout rather than DOM timeout
triggerTimeout.current = global.setTimeout(() => {
setState('default');
if (state === 'ask') {
if (triggerTimeout.current !== null) {
// Clear existing timeouts
clearTimeout(triggerTimeout.current);
}
// Fire the click handler
onClick?.(event);
}, 2000);
};
const handleAsk = (event: MouseEvent<HTMLButtonElement>) => {
// Prevent events (ex. won't close dropdown if it's in one)
event.preventDefault();
event.stopPropagation();
// Toggle the confirmation notice
setState('ask');
// Set a timeout to hide the confirmation
// using global.setTimeout to force use of the Node timeout rather than DOM timeout
triggerTimeout.current = global.setTimeout(() => {
setState('default');
}, 2000);
};
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (state === 'ask') {
handleConfirm(event);
} else if (state === 'default') {
handleAsk(event);
// Set the state to done (but delay a bit to not alarm user)
// using global.setTimeout to force use of the Node timeout rather than DOM timeout
doneTimeout.current = global.setTimeout(() => {
setState('done');
}, 100);
// Set a timeout to hide the confirmation
// using global.setTimeout to force use of the Node timeout rather than DOM timeout
triggerTimeout.current = global.setTimeout(() => {
setState('default');
}, 2000);
}
};

View File

@ -1,25 +1,23 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames';
import React, { Fragment, PureComponent } from 'react';
import { connect } from 'react-redux';
import { AnyAction, bindActionCreators, Dispatch } from 'redux';
import React, { FC, Fragment, useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useInterval, useMount } from 'react-use';
import * as session from '../../../account/session';
import { AUTOBIND_CFG, DEFAULT_BRANCH_NAME } from '../../../common/constants';
import { 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 { Snapshot, Status, StatusCandidate } from '../../../sync/types';
import { Status } 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 { selectActiveWorkspaceMeta, selectRemoteProjects, selectSyncItems } from '../../redux/selectors';
import { Dropdown } from '../base/dropdown/dropdown';
@ -37,32 +35,15 @@ 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;
// TODO: handle refetching logic in one place not here in a component
// Refresh dropdown periodically
const REFRESH_PERIOD = 1000 * 60 * 1;
type ReduxProps = ReturnType<typeof mapStateToProps> & ReturnType<typeof mapDispatchToProps>;
const mapStateToProps = (state: RootState) => ({
remoteProjects: selectRemoteProjects(state),
syncItems: selectSyncItems(state),
workspaceMeta: selectActiveWorkspaceMeta(state),
});
const mapDispatchToProps = (dispatch: Dispatch<AnyAction>) => {
const bound = bindActionCreators({ activateWorkspace }, dispatch);
return {
handleActivateWorkspace: bound.activateWorkspace,
};
};
interface Props extends ReduxProps {
interface Props {
workspace: Workspace;
project: Project;
vcs: VCS;
syncItems: StatusCandidate[];
className?: string;
}
interface State {
@ -80,14 +61,8 @@ interface State {
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 = {
export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
const [state, setState] = useState<State>({
localBranches: [],
currentBranch: '',
compare: {
@ -105,133 +80,113 @@ class UnconnectedSyncDropdown extends PureComponent<Props, State> {
unstaged: {},
},
remoteBackendProjects: [],
};
});
const dispatch = useDispatch();
const remoteProjects = useSelector(selectRemoteProjects);
const syncItems = useSelector(selectSyncItems);
const workspaceMeta = useSelector(selectActiveWorkspaceMeta);
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
const refetchRemoteBranch = useCallback(async () => {
if (session.isLoggedIn()) {
try {
newState.compare = await vcs.compareRemoteBranch();
const compare = await vcs.compareRemoteBranch();
setState(state => ({
...state,
compare,
}));
} catch (err) {
console.log('Failed to compare remote branches', err.message);
}
}
}, [vcs]);
this.setState(prevState => ({ ...prevState, ...newState }));
}
const refreshVCSAndRefetchRemote = useCallback(async () => {
if (!vcs.hasBackendProject() && isRemoteProject(project)) {
const remoteBackendProjects = await vcs.remoteBackendProjectsInAnyTeam();
const matchedBackendProjects = remoteBackendProjects.filter(p => p.rootDocumentId === workspace._id);
setState(state => ({
...state,
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, {});
setState(state => ({
...state,
status,
historyCount,
localBranches,
currentBranch,
}));
// Do the remote stuff
refetchRemoteBranch();
}, [project, refetchRemoteBranch, syncItems, vcs, workspace._id]);
async componentDidMount() {
this.setState({
useInterval(() => {
refetchRemoteBranch();
}, REFRESH_PERIOD);
useMount(async () => {
setState(state => ({
...state,
initializing: true,
});
const { vcs, workspace, workspaceMeta, project } = this.props;
}));
try {
// NOTE pushes the first snapshot automatically
await pushSnapshotOnInitialize({ vcs, workspace, workspaceMeta, project });
await this.refreshMainAttributes();
await refreshVCSAndRefetchRemote();
} catch (err) {
console.log('[sync_menu] Error refreshing sync state', err);
} finally {
this.setState({
setState(state => ({
...state,
initializing: false,
}));
}
});
// Update if new sync items
useEffect(() => {
if (vcs.hasBackendProject()) {
vcs.status(syncItems, {}).then(status => {
setState(state => ({
...state,
status,
}));
});
}
// 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);
}, [syncItems, vcs]);
async function handleSetProject(backendProject: BackendProjectWithTeam) {
setState(state => ({
...state,
loadingProjectPull: true,
}));
const pulledIntoProject = await pullBackendProject({ vcs, backendProject, remoteProjects });
if (pulledIntoProject._id !== project._id) {
// If pulled into a different project, reactivate the workspace
await dispatch(activateWorkspace({ workspaceId: workspace._id }));
logCollectionMovedToProject(workspace, pulledIntoProject);
}
document.removeEventListener('mousemove', this._handleUserActivity);
await refreshVCSAndRefetchRemote();
setState(state => ({
...state,
loadingProjectPull: false,
}));
}
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(LoginModalHandle);
}
async _handlePushChanges() {
const { vcs, project: { remoteId } } = this.props;
this.setState({
async function handlePush() {
setState(state => ({
...state,
loadingPush: true,
});
}));
try {
const branch = await vcs.getBranch();
await interceptAccessError({
callback: async () => await vcs.push(remoteId),
callback: () => vcs.push(project.remoteId),
action: 'push',
resourceName: branch,
resourceType: 'branch',
@ -244,28 +199,29 @@ class UnconnectedSyncDropdown extends PureComponent<Props, State> {
});
}
await this.refreshMainAttributes({
await refreshVCSAndRefetchRemote();
setState(state => ({
...state,
loadingPush: false,
});
}));
}
async _handlePullChanges() {
const { vcs, syncItems, project: { remoteId } } = this.props;
this.setState({
async function handlePull() {
setState(state => ({
...state,
loadingPull: true,
});
}));
try {
const branch = await vcs.getBranch();
const delta = await interceptAccessError({
callback: async () => await vcs.pull(syncItems, remoteId),
callback: () => vcs.pull(syncItems, project.remoteId),
action: 'pull',
resourceName: branch,
resourceType: 'branch',
});
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
this.refreshOnNextSyncItems = true;
} catch (err) {
showError({
title: 'Pull Error',
@ -274,22 +230,13 @@ class UnconnectedSyncDropdown extends PureComponent<Props, State> {
});
}
this.setState({
setState(state => ({
...state,
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;
async function handleRevert() {
try {
const delta = await vcs.rollbackToLatest(syncItems);
// @ts-expect-error -- TSCONVERSION
@ -303,61 +250,11 @@ class UnconnectedSyncDropdown extends PureComponent<Props, State> {
}
}
_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;
async function handleSwitchBranch(branch: string) {
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
@ -365,7 +262,6 @@ class UnconnectedSyncDropdown extends PureComponent<Props, State> {
delta.remove = delta.remove.filter(e => e?.type !== models.workspace.type);
}
}
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
} catch (err) {
@ -375,257 +271,248 @@ class UnconnectedSyncDropdown extends PureComponent<Props, State> {
error: err,
});
}
// 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({
setState(state => ({
...state,
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;
if (!session.isLoggedIn()) {
return null;
}
const {
localBranches,
currentBranch,
status,
historyCount,
loadingPull,
loadingPush,
loadingProjectPull,
compare: { ahead, behind },
initializing,
remoteBackendProjects,
} = 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 (
<DropdownItem
key={branch}
onClick={isCurrentBranch ? undefined : () => this._handleSwitchBranch(branch)}
className={classnames({
bold: isCurrentBranch,
})}
title={isCurrentBranch ? '' : `Switch to "${branch}"`}
>
{icon}
{branch}
</DropdownItem>
<div>
<button className="btn btn--compact wide">
<i className="fa fa-refresh fa-spin" /> Initializing
</button>
</div>
);
}
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>;
}
if (!vcs.hasBackendProject()) {
return (
<DropdownButton
className="btn--clicky-small btn-sync 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="flex 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()}
<div>
<Dropdown className="wide tall" onOpen={() => refreshVCSAndRefetchRemote()}>
<DropdownButton className="btn btn--compact wide">
<i className="fa fa-code-fork " /> Setup Sync
</DropdownButton>
{syncMenuHeader}
{!session.isLoggedIn() && (
<DropdownItem onClick={SyncDropdown._handleShowLoginModal}>
<i className="fa fa-sign-in" /> Log In
{remoteBackendProjects.length === 0 && (
<DropdownItem
onClick={async () => {
setState(state => ({
...state,
loadingProjectPull: true,
}));
await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name);
await refreshVCSAndRefetchRemote();
setState(state => ({
...state,
loadingProjectPull: false,
}));
}}
>
<i className="fa fa-plus-circle" /> Create Locally
</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-download" /> 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>
{remoteBackendProjects.map(p => (
<DropdownItem key={p.id} onClick={() => handleSetProject(p)}>
<i className="fa fa-cloud-download" /> Pull <strong>{p.name}</strong>
</DropdownItem>
))}
</Dropdown>
</div>
);
}
}
const canPush = ahead > 0;
const canPull = behind > 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';
export const SyncDropdown = connect(mapStateToProps, mapDispatchToProps)(UnconnectedSyncDropdown);
return (
<div>
<Dropdown className="wide tall" onOpen={() => refreshVCSAndRefetchRemote()}>
{currentBranch === null ?
<Fragment>Sync</Fragment> :
<DropdownButton
className="btn--clicky-small btn-sync 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="flex 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>}
{syncMenuHeader}
{!session.isLoggedIn() && (
<DropdownItem onClick={() => showModal(LoginModalHandle)}>
<i className="fa fa-sign-in" /> Log In
</DropdownItem>
)}
<DropdownItem onClick={() => showModal(SyncBranchesModal, { onHide: refreshVCSAndRefetchRemote })}>
<i className="fa fa-code-fork" />
Branches
</DropdownItem>
<DropdownItem onClick={() => showModal(SyncDeleteModal, { onHide: refreshVCSAndRefetchRemote })} disabled={historyCount === 0}>
<i className="fa fa-remove" />
Delete {strings.collection.singular}
</DropdownItem>
<DropdownDivider>Local Branches</DropdownDivider>
{visibleBranches.map(branch => {
const icon = branch === currentBranch ? <i className="fa fa-tag" /> : <i className="fa fa-empty" />;
const isCurrentBranch = branch === currentBranch;
return <DropdownItem
key={branch}
onClick={isCurrentBranch ? undefined : () => handleSwitchBranch(branch)}
className={classnames({
bold: isCurrentBranch,
})}
title={isCurrentBranch ? '' : `Switch to "${branch}"`}
>
{icon}
{branch}
</DropdownItem>;
})}
<DropdownDivider>{currentBranch}</DropdownDivider>
<DropdownItem onClick={() => showModal(SyncHistoryModal)} disabled={historyCount === 0}>
<i className="fa fa-clock-o" />
History
</DropdownItem>
<DropdownItem
onClick={handleRevert}
buttonClass={PromptButton}
stayOpenAfterClick
disabled={!canCreateSnapshot || historyCount === 0}
>
<i className="fa fa-undo" />
Revert Changes
</DropdownItem>
<DropdownItem
onClick={() =>
showModal(SyncStagingModal, {
onSnapshot: refreshVCSAndRefetchRemote,
handlePush,
})
}
disabled={!canCreateSnapshot}
>
<i className="fa fa-cube" />
Create Snapshot
</DropdownItem>
<DropdownItem onClick={handlePull} disabled={behind === 0 || loadingPull}>
{loadingPull ? (
<Fragment>
<i className="fa fa-spin fa-refresh" /> Pulling Snapshots...
</Fragment>
) : (
<Fragment>
<i className="fa fa-cloud-download" /> Pull {behind || ''} Snapshot
{behind === 1 ? '' : 's'}
</Fragment>
)}
</DropdownItem>
<DropdownItem onClick={handlePush} 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>
);
};

View File

@ -89,15 +89,15 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
};
// TODO: if gRPC, we should also copy the protofile to the destination workspace - INS-267
await requestOperations.update(request, patch);
setState({
setState(state => ({
...state,
justMoved: true,
});
}));
setTimeout(() => {
setState({
setState(state => ({
...state,
justMoved: false,
});
}));
}, 2000);
}
@ -119,15 +119,15 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
};
// TODO: if gRPC, we should also copy the protofile to the destination workspace - INS-267
await requestOperations.duplicate(request, patch);
setState({
setState(state => ({
...state,
justCopied: true,
});
}));
setTimeout(() => {
setState({
setState(state => ({
...state,
justCopied: false,
});
}));
}, 2000);
models.stats.incrementCreatedRequests();
}
@ -139,9 +139,9 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
const updated = await requestOperations.update(request, {
[event.currentTarget.name]: event.currentTarget.checked,
});
setState({
setState(state => ({
...state, request: updated,
});
}));
};
return (
@ -195,7 +195,7 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
/>
) : (
<button
onClick={() => setState({ ...state, showDescription: true })}
onClick={() => setState(state => ({ ...state, showDescription: true }))}
className="btn btn--outlined btn--super-duper-compact"
>
Add Description
@ -237,7 +237,7 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
const updated = await requestOperations.update(request, {
[event.currentTarget.name]: event.currentTarget.value,
});
setState({ ...state, request: updated });
setState(state => ({ ...state, request: updated }));
}}
>
<option value={'global'}>Use global setting</option>
@ -261,7 +261,7 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
onChange={event => {
const { value } = event.currentTarget;
const workspaceId = value === '__NULL__' ? null : value;
setState({ ...state, activeWorkspaceIdToCopyTo: workspaceId });
setState(state => ({ ...state, activeWorkspaceIdToCopyTo: workspaceId }));
}}
>
<option value="__NULL__">-- Select Workspace --</option>
@ -319,16 +319,16 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
const updated = await models.request.update(request, {
description,
});
setState({
setState(state => ({
...state,
request: updated,
defaultPreviewMode: false,
});
}));
}}
/>
) : (
<button
onClick={() => setState({ ...state, showDescription: true })}
onClick={() => setState(state => ({ ...state, showDescription: true }))}
className="btn btn--outlined btn--super-duper-compact"
>
Add Description
@ -415,7 +415,7 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
const updated = await models.request.update(request, {
[event.currentTarget.name]: event.currentTarget.value,
});
setState({ ...state, request: updated });
setState(state => ({ ...state, request: updated }));
}}
>
<option value={'global'}>Use global setting</option>
@ -439,7 +439,7 @@ export const RequestSettingsModal = forwardRef<RequestSettingsModalHandle, Modal
onChange={event => {
const { value } = event.currentTarget;
const workspaceId = value === '__NULL__' ? null : value;
setState({ ...state, activeWorkspaceIdToCopyTo: workspaceId });
setState(state => ({ ...state, activeWorkspaceIdToCopyTo: workspaceId }));
}}
>
<option value="__NULL__">-- Select Workspace --</option>

View File

@ -1,154 +1,51 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AUTOBIND_CFG } from '../../../common/constants';
import { database as db } from '../../../common/database';
import { interceptAccessError } from '../../../sync/vcs/util';
import { VCS } from '../../../sync/vcs/vcs';
import { RootState } from '../../redux/modules';
import { selectSyncItems } from '../../redux/selectors';
import { type ModalHandle, Modal } from '../base/modal';
import { type ModalHandle, Modal, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalHeader } from '../base/modal-header';
import { PromptButton } from '../base/prompt-button';
import { SyncPullButton } from '../sync-pull-button';
type ReduxProps = ReturnType<typeof mapStateToProps>;
interface Props extends ReduxProps {
type Props = ModalProps & {
vcs: VCS;
}
interface State {
};
export interface SyncBranchesModalOptions {
error: string;
newBranchName: string;
currentBranch: string;
branches: string[];
remoteBranches: string[];
onHide?: () => void;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class UnconnectedSyncBranchesModal extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
state: State = {
export interface SyncBranchesModalHandle {
show: (options: SyncBranchesModalOptions) => void;
hide: () => void;
}
export const SyncBranchesModal = forwardRef<SyncBranchesModalHandle, Props>(({ vcs }, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<SyncBranchesModalOptions>({
error: '',
newBranchName: '',
branches: [],
remoteBranches: [],
currentBranch: '',
};
_setModalRef(modal: ModalHandle) {
this.modal = modal;
}
async _handleCheckout(branch: string) {
const { vcs, syncItems } = this.props;
try {
const delta = await vcs.checkout(syncItems, branch);
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
await this.refreshState();
} catch (err) {
console.log('Failed to checkout', err.stack);
this.setState({
error: err.message,
});
}
}
async _handleMerge(branch: string) {
const { vcs, syncItems } = this.props;
const delta = await vcs.merge(syncItems, branch);
try {
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
await this.refreshState();
} catch (err) {
console.log('Failed to merge', err.stack);
this.setState({
error: err.message,
});
}
}
async _handleRemoteDelete(branch: string) {
const { vcs } = this.props;
try {
await vcs.removeRemoteBranch(branch);
await this.refreshState();
} catch (err) {
console.log('Failed to remote delete', err.stack);
this.setState({
error: err.message,
});
}
}
async _handleDelete(branch: string) {
const { vcs } = this.props;
try {
await vcs.removeBranch(branch);
await this.refreshState();
} catch (err) {
console.log('Failed to delete', err.stack);
this.setState({
error: err.message,
});
}
}
async _handleCreate(event: React.SyntheticEvent<HTMLFormElement>) {
event.preventDefault();
const { vcs, syncItems } = this.props;
try {
// Create new branch
const { newBranchName } = this.state;
await vcs.fork(newBranchName);
// Checkout new branch
const delta = await vcs.checkout(syncItems, newBranchName);
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
// Clear branch name and refresh things
await this.refreshState({
newBranchName: '',
});
} catch (err) {
console.log('Failed to create', err.stack);
this.setState({
error: err.message,
});
}
}
_updateNewBranchName(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) {
this.setState({ newBranchName: event.currentTarget.value });
}
_handleClearError() {
this.setState({ error: '' });
}
async refreshState(newState?: Record<string, any>) {
const { vcs } = this.props;
});
const refreshState = useCallback(async () => {
try {
const currentBranch = await vcs.getBranch();
const branches = (await vcs.getBranches()).sort();
this.setState({
setState(state => ({
...state,
branches,
currentBranch,
error: '',
...newState,
});
}));
const remoteBranches = await interceptAccessError({
callback: async () => (await vcs.getRemoteBranches()).filter(b => !branches.includes(b)).sort(),
@ -156,173 +53,244 @@ export class UnconnectedSyncBranchesModal extends PureComponent<Props, State> {
resourceName: 'remote',
resourceType: 'branches',
});
this.setState({
setState(state => ({
...state,
remoteBranches,
});
}));
} catch (err) {
console.log('Failed to refresh', err.stack);
this.setState({
setState(state => ({
...state,
error: err.message,
});
}));
}
}, [vcs]);
useImperativeHandle(ref, () => ({
hide: () => modalRef.current?.hide(),
show: ({ onHide }) => {
setState(state => ({
...state,
onHide,
}));
refreshState();
modalRef.current?.show({ onHide });
},
}), [refreshState]);
const syncItems = useSelector(selectSyncItems);
async function handleCheckout(branch: string) {
try {
const delta = await vcs.checkout(syncItems, branch);
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
await refreshState();
} catch (err) {
console.log('Failed to checkout', err.stack);
setState(state => ({
...state,
error: err.message,
}));
}
}
const handleMerge = async (branch: string) => {
const delta = await vcs.merge(syncItems, branch);
try {
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
await refreshState();
} catch (err) {
console.log('Failed to merge', err.stack);
setState(state => ({
...state,
error: err.message,
}));
}
};
hide() {
this.modal?.hide();
}
const handleRemoteDelete = async (branch: string) => {
try {
await vcs.removeRemoteBranch(branch);
await refreshState();
} catch (err) {
console.log('Failed to remote delete', err.stack);
setState(state => ({
...state,
error: err.message,
}));
}
};
async show() {
this.modal && this.modal.show();
await this.refreshState();
}
const handleDelete = async (branch: string) => {
try {
await vcs.removeBranch(branch);
await refreshState();
} catch (err) {
console.log('Failed to delete', err.stack);
setState(state => ({
...state,
error: err.message,
}));
}
};
render() {
const { vcs } = this.props;
const { branches, remoteBranches, currentBranch, newBranchName, error } = this.state;
return (
<Modal ref={this._setModalRef}>
<ModalHeader>Branches</ModalHeader>
<ModalBody className="wide pad">
{error && (
<p className="notice error margin-bottom-sm no-margin-top">
<button className="pull-right icon" onClick={this._handleClearError}>
<i className="fa fa-times" />
</button>
{error}
</p>
)}
<form onSubmit={this._handleCreate}>
<div className="form-row">
<div className="form-control form-control--outlined">
<label>
New Branch Name
<input
type="text"
onChange={this._updateNewBranchName}
placeholder="testing-branch"
value={newBranchName}
/>
</label>
</div>
<div className="form-control form-control--no-label width-auto">
<button type="submit" className="btn btn--clicky" disabled={!newBranchName}>
Create
</button>
</div>
const handleCreate = async (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
try {
// Create new branch
const { newBranchName } = state;
await vcs.fork(newBranchName);
// Checkout new branch
const delta = await vcs.checkout(syncItems, newBranchName);
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
// Clear branch name and refresh things
await refreshState();
setState(state => ({
...state,
newBranchName: '',
}));
} catch (err) {
console.log('Failed to create', err.stack);
setState(state => ({
...state,
error: err.message,
}));
}
};
const { branches, remoteBranches, currentBranch, newBranchName, error } = state;
return (
<Modal ref={modalRef}>
<ModalHeader>Branches</ModalHeader>
<ModalBody className="wide pad">
{error && (
<p className="notice error margin-bottom-sm no-margin-top">
<button className="pull-right icon" onClick={() => setState(state => ({ ...state, error: '' }))}>
<i className="fa fa-times" />
</button>
{error}
</p>
)}
<form onSubmit={handleCreate}>
<div className="form-row">
<div className="form-control form-control--outlined">
<label>
New Branch Name
<input
type="text"
onChange={event => setState(state => ({ ...state, newBranchName: event.target.value }))}
placeholder="testing-branch"
value={newBranchName}
/>
</label>
</div>
</form>
<div className="form-control form-control--no-label width-auto">
<button type="submit" className="btn btn--clicky" disabled={!newBranchName}>
Create
</button>
</div>
</div>
</form>
<div className="pad-top">
<table className="table--fancy table--outlined">
<thead>
<tr>
<th className="text-left">Branches</th>
<th className="text-right">&nbsp;</th>
</tr>
</thead>
<tbody>
{branches.map(name => (
<tr key={name} className="table--no-outline-row">
<td>
<span
className={classnames({
bold: name === currentBranch,
})}
>
{name}
</span>
{name === currentBranch ? (
<span className="txt-sm space-left">(current)</span>
) : null}
{name === 'master' && <i className="fa fa-lock space-left faint" />}
</td>
<td className="text-right">
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Merged"
disabled={name === currentBranch}
onClick={() => handleMerge(name)}
>
Merge
</PromptButton>
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Deleted"
disabled={name === currentBranch || name === 'master'}
onClick={() => handleDelete(name)}
>
Delete
</PromptButton>
<button
className="btn btn--micro btn--outlined space-left"
disabled={name === currentBranch}
onClick={() => handleCheckout(name)}
>
Checkout
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{remoteBranches.length > 0 && (
<div className="pad-top">
<table className="table--fancy table--outlined">
<thead>
<tr>
<th className="text-left">Branches</th>
<th className="text-left">Remote Branches</th>
<th className="text-right">&nbsp;</th>
</tr>
</thead>
<tbody>
{branches.map(name => (
{remoteBranches.map(name => (
<tr key={name} className="table--no-outline-row">
<td>
<span
className={classnames({
bold: name === currentBranch,
})}
>
{name}
</span>
{name === currentBranch ? (
<span className="txt-sm space-left">(current)</span>
) : null}
{name}
{name === 'master' && <i className="fa fa-lock space-left faint" />}
</td>
<td className="text-right">
<PromptButton
{name !== 'master' && (
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Deleted"
disabled={name === currentBranch}
onClick={() => handleRemoteDelete(name)}
>
Delete
</PromptButton>
)}
<SyncPullButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Merged"
branch={name}
onPull={refreshState}
disabled={name === currentBranch}
onClick={() => this._handleMerge(name)}
vcs={vcs}
>
Merge
</PromptButton>
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Deleted"
disabled={name === currentBranch || name === 'master'}
onClick={() => this._handleDelete(name)}
>
Delete
</PromptButton>
<button
className="btn btn--micro btn--outlined space-left"
disabled={name === currentBranch}
onClick={() => this._handleCheckout(name)}
>
Checkout
</button>
Fetch
</SyncPullButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
{remoteBranches.length > 0 && (
<div className="pad-top">
<table className="table--fancy table--outlined">
<thead>
<tr>
<th className="text-left">Remote Branches</th>
<th className="text-right">&nbsp;</th>
</tr>
</thead>
<tbody>
{remoteBranches.map(name => (
<tr key={name} className="table--no-outline-row">
<td>
{name}
{name === 'master' && <i className="fa fa-lock space-left faint" />}
</td>
<td className="text-right">
{name !== 'master' && (
<PromptButton
className="btn btn--micro btn--outlined space-left"
doneMessage="Deleted"
disabled={name === currentBranch}
onClick={() => this._handleRemoteDelete(name)}
>
Delete
</PromptButton>
)}
<SyncPullButton
className="btn btn--micro btn--outlined space-left"
branch={name}
onPull={this.refreshState}
disabled={name === currentBranch}
vcs={vcs}
>
Fetch
</SyncPullButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</ModalBody>
</Modal>
);
}
}
const mapStateToProps = (state: RootState) => ({
syncItems: selectSyncItems(state),
)}
</ModalBody>
</Modal >
);
});
export const SyncBranchesModal = connect(
mapStateToProps,
null,
null,
{ forwardRef: true },
)(UnconnectedSyncBranchesModal);
SyncBranchesModal.displayName = 'SyncBranchesModal';

View File

@ -1,141 +1,88 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AUTOBIND_CFG } from '../../../common/constants';
import { strings } from '../../../common/strings';
import { interceptAccessError } from '../../../sync/vcs/util';
import { VCS } from '../../../sync/vcs/vcs';
import { RootState } from '../../redux/modules';
import { Button } from '../../components/themed-button';
import { selectActiveWorkspace } from '../../redux/selectors';
import { type ModalHandle, Modal } from '../base/modal';
import { type ModalHandle, Modal, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalHeader } from '../base/modal-header';
import { Button } from '../themed-button';
type ReduxProps = ReturnType<typeof mapStateToProps>;
interface Props extends ReduxProps {
type Props = ModalProps & {
vcs: VCS;
}
interface State {
};
export interface SyncDeleteModalOptions {
error: string;
workspaceName: string;
onHide?: () => void;
}
export interface SyncDeleteModalHandle {
show: (options: SyncDeleteModalOptions) => void;
hide: () => void;
}
export const SyncDeleteModal = forwardRef<SyncDeleteModalHandle, Props>(({ vcs }, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<SyncDeleteModalOptions>({
error: '',
workspaceName: '',
});
const INITIAL_STATE: State = {
error: '',
workspaceName: '',
};
@autoBindMethodsForReact(AUTOBIND_CFG)
export class UnconnectedSyncDeleteModal extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
input: HTMLInputElement | null = null;
constructor(props: Props) {
super(props);
this.state = INITIAL_STATE;
}
_setModalRef(modal: ModalHandle) {
this.modal = modal;
}
_setInputRef(input: HTMLInputElement) {
this.input = input;
}
_updateWorkspaceName(event: React.SyntheticEvent<HTMLInputElement>) {
this.setState({
workspaceName: event.currentTarget.value,
});
}
async _handleDelete(event: React.SyntheticEvent<HTMLFormElement>) {
useImperativeHandle(ref, () => ({
hide: () => modalRef.current?.hide(),
show: ({ onHide }) => {
setState({
error: '',
workspaceName: '',
onHide,
});
modalRef.current?.show({ onHide });
},
}), []);
const activeWorkspace = useSelector(selectActiveWorkspace);
const onSubmit = async (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
const { vcs } = this.props;
const { workspaceName } = this.state;
try {
await interceptAccessError({
action: 'delete',
callback: () => vcs.archiveProject(),
resourceName: workspaceName,
resourceName: state.workspaceName,
resourceType: strings.collection.singular.toLowerCase(),
});
this.hide();
modalRef.current?.hide();
} catch (err) {
this.setState({
setState(state => ({
...state,
error: err.message,
});
}));
}
}
};
const { error, workspaceName } = state;
async show() {
this.modal && this.modal.show();
// Reset state
this.setState(INITIAL_STATE);
// Focus input when modal shows
setTimeout(() => {
this.input?.focus();
}, 100);
}
hide() {
this.modal?.hide();
}
render() {
const { error, workspaceName } = this.state;
const { activeWorkspace } = this.props;
const workspaceNameElement = (
<strong
style={{
whiteSpace: 'pre-wrap',
}}
>
{activeWorkspace?.name}
</strong>
);
return (
<Modal ref={this._setModalRef} skinny>
<ModalHeader>Delete {strings.collection.singular}</ModalHeader>
<ModalBody className="wide pad-left pad-right text-center" noScroll>
{error && <p className="notice error margin-bottom-sm no-margin-top">{error}</p>}
<p className="selectable">
This will permanently delete the {workspaceNameElement}{' '}
{strings.collection.singular.toLowerCase()} remotely.
</p>
<p className="selectable">Please type {workspaceNameElement} to confirm.</p>
<form onSubmit={this._handleDelete}>
<div className="form-control form-control--outlined">
<input
ref={this._setInputRef}
type="text"
onChange={this._updateWorkspaceName}
value={workspaceName}
/>
<Button bg="danger" disabled={workspaceName !== activeWorkspace?.name}>
Delete {strings.collection.singular}
</Button>
</div>
</form>
</ModalBody>
</Modal>
);
}
}
const mapStateToProps = (state: RootState) => ({
activeWorkspace: selectActiveWorkspace(state),
return (
<Modal ref={modalRef} skinny>
<ModalHeader>Delete {strings.collection.singular}</ModalHeader>
<ModalBody className="wide pad-left pad-right text-center" noScroll>
{error && <p className="notice error margin-bottom-sm no-margin-top">{error}</p>}
<p className="selectable">
This will permanently delete the {<strong style={{ whiteSpace: 'pre-wrap' }}>{activeWorkspace?.name}</strong>}{' '}
{strings.collection.singular.toLowerCase()} remotely.
</p>
<p className="selectable">Please type {<strong style={{ whiteSpace: 'pre-wrap' }}>{activeWorkspace?.name}</strong>} to confirm.</p>
<form onSubmit={onSubmit}>
<div className="form-control form-control--outlined">
<input
type="text"
onChange={event => setState(state => ({ ...state, workspaceName: event.target.value }))}
value={workspaceName}
/>
<Button bg="danger" disabled={workspaceName !== activeWorkspace?.name}>
Delete {strings.collection.singular}
</Button>
</div>
</form>
</ModalBody>
</Modal>
);
});
export const SyncDeleteModal = connect(
mapStateToProps,
null,
null,
{ forwardRef: true },
)(UnconnectedSyncDeleteModal);
SyncDeleteModal.displayName = 'SyncDeleteModal';

View File

@ -1,11 +1,11 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { Fragment, PureComponent } from 'react';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import * as session from '../../../account/session';
import { AUTOBIND_CFG } from '../../../common/constants';
import type { Snapshot } from '../../../sync/types';
import { VCS } from '../../../sync/vcs/vcs';
import { type ModalHandle, Modal } from '../base/modal';
import { selectSyncItems } from '../../redux/selectors';
import { type ModalHandle, Modal, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalHeader } from '../base/modal-header';
import { PromptButton } from '../base/prompt-button';
@ -13,143 +13,119 @@ import { HelpTooltip } from '../help-tooltip';
import { TimeFromNow } from '../time-from-now';
import { Tooltip } from '../tooltip';
interface Props {
type Props = ModalProps & {
vcs: VCS;
}
};
interface State {
branch: string;
history: Snapshot[];
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class SyncHistoryModal extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
handleRollback?: (arg0: Snapshot) => Promise<void>;
state: State = {
export interface SyncHistoryModalHandle {
show: () => void;
hide: () => void;
}
export const SyncHistoryModal = forwardRef<SyncHistoryModalHandle, Props>(({ vcs }, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<State>({
branch: '',
history: [],
};
_setModalRef(modal: ModalHandle) {
this.modal = modal;
}
async _handleClickRollback(snapshot: Snapshot) {
// TODO: unsound non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await this.handleRollback!(snapshot);
await this.refreshState();
}
async refreshState(newState?: Record<string, any>) {
const { vcs } = this.props;
const branch = await vcs.getBranch();
const history = await vcs.getHistory();
this.setState({
branch,
history: history.sort((a, b) => (a.created < b.created ? 1 : -1)),
...newState,
});
}
hide() {
this.modal?.hide();
}
async show(options: { handleRollback: (arg0: Snapshot) => Promise<void> }) {
this.modal?.show();
this.handleRollback = options.handleRollback;
await this.refreshState();
}
static renderAuthorName(snapshot: Snapshot) {
let name = '';
let email = '';
});
const syncItems = useSelector(selectSyncItems);
useImperativeHandle(ref, () => ({
hide: () => {
modalRef.current?.hide();
},
show: async () => {
const branch = await vcs.getBranch();
const history = await vcs.getHistory();
setState({
branch,
history: history.sort((a, b) => (a.created < b.created ? 1 : -1)),
});
modalRef.current?.show();
},
}), [vcs]);
const authorName = (snapshot: Snapshot) => {
let fullName = '';
if (snapshot.authorAccount) {
const { firstName, lastName } = snapshot.authorAccount;
name += `${firstName} ${lastName}`;
email = snapshot.authorAccount.email;
fullName += `${firstName} ${lastName}`;
}
if (snapshot.author === session.getAccountId()) {
name += ' (you)';
fullName += ' (you)';
}
if (name) {
return (
<Fragment>
{name}{' '}
<HelpTooltip
info
// @ts-expect-error -- TSCONVERSION
delay={500}
>
{email}
</HelpTooltip>
</Fragment>
);
} else {
return '--';
}
}
render() {
const { branch, history } = this.state;
return (
<Modal ref={this._setModalRef}>
<ModalHeader>
Branch History: <i>{branch}</i>
</ModalHeader>
<ModalBody className="wide pad">
<table className="table--fancy table--striped">
<thead>
<tr>
<th className="text-left">Message</th>
<th className="text-left">When</th>
<th className="text-left">Author</th>
<th className="text-right">Objects</th>
<th className="text-right">
Restore
<HelpTooltip>
This will revert the workspace to that state stored in the snapshot
</HelpTooltip>
</th>
</tr>
</thead>
<tbody>
{history.map(snapshot => (
<tr key={snapshot.id}>
<td>
<Tooltip message={snapshot.id} selectable wide delay={500}>
{snapshot.name}
</Tooltip>
</td>
<td>
<TimeFromNow
className="no-wrap"
timestamp={snapshot.created}
intervalSeconds={30}
/>
</td>
<td className="text-left">{SyncHistoryModal.renderAuthorName(snapshot)}</td>
<td className="text-right">{snapshot.state.length}</td>
<td className="text-right">
<PromptButton
className="btn btn--micro btn--outlined"
onClick={() => this._handleClickRollback(snapshot)}
return fullName;
};
const { branch, history } = state;
return (
<Modal ref={modalRef}>
<ModalHeader>
Branch History: <i>{branch}</i>
</ModalHeader>
<ModalBody className="wide pad">
<table className="table--fancy table--striped">
<thead>
<tr>
<th className="text-left">Message</th>
<th className="text-left">When</th>
<th className="text-left">Author</th>
<th className="text-right">Objects</th>
<th className="text-right">
Restore
<HelpTooltip>
This will revert the workspace to that state stored in the snapshot
</HelpTooltip>
</th>
</tr>
</thead>
<tbody>
{history.map(snapshot => (
<tr key={snapshot.id}>
<td>
<Tooltip message={snapshot.id} selectable wide delay={500}>
{snapshot.name}
</Tooltip>
</td>
<td>
<TimeFromNow
className="no-wrap"
timestamp={snapshot.created}
intervalSeconds={30}
/>
</td>
<td className="text-left">{Boolean(authorName(snapshot)) ? (
<>
{authorName(snapshot)}{' '}
<HelpTooltip
info
// @ts-expect-error -- TSCONVERSION
delay={500}
>
Restore
</PromptButton>
</td>
</tr>
))}
</tbody>
</table>
</ModalBody>
</Modal>
);
}
}
{snapshot.authorAccount?.email || ''}
</HelpTooltip>
</>
) : '--'}</td>
<td className="text-right">{snapshot.state.length}</td>
<td className="text-right">
<PromptButton
className="btn btn--micro btn--outlined"
onClick={async () => {
const delta = await vcs.rollback(snapshot.id, syncItems);
// @ts-expect-error -- TSCONVERSION
await db.batchModifyDocs(delta);
}}
>
Restore
</PromptButton>
</td>
</tr>
))}
</tbody>
</table>
</ModalBody>
</Modal>
);
});
SyncHistoryModal.displayName = 'SyncHistoryModal';

View File

@ -1,143 +1,103 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { AUTOBIND_CFG } from '../../../common/constants';
import type { DocumentKey, MergeConflict } from '../../../sync/types';
import { VCS } from '../../../sync/vcs/vcs';
import { RootState } from '../../redux/modules';
import { selectSyncItems } from '../../redux/selectors';
import { type ModalHandle, Modal } from '../base/modal';
import type { MergeConflict } from '../../../sync/types';
import { type ModalHandle, Modal, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalFooter } from '../base/modal-footer';
import { ModalHeader } from '../base/modal-header';
type ReduxProps = ReturnType<typeof mapStateToProps>;
interface Props extends ReduxProps {
vcs: VCS;
export interface SyncMergeModalOptions {
conflicts?: MergeConflict[];
handleDone?: (conflicts?: MergeConflict[]) => void;
}
interface State {
conflicts: MergeConflict[];
export interface SyncMergeModalHandle {
show: (options: SyncMergeModalOptions) => void;
hide: () => void;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class UnconnectedSyncMergeModal extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
_handleDone?: (arg0: MergeConflict[]) => void;
state: State = {
export const SyncMergeModal = forwardRef<SyncMergeModalHandle, ModalProps>((_, ref) => {
const modalRef = useRef<ModalHandle>(null);
const [state, setState] = useState<SyncMergeModalOptions>({
conflicts: [],
};
});
_setModalRef(modal: ModalHandle) {
this.modal = modal;
}
useImperativeHandle(ref, () => ({
hide: () => modalRef.current?.hide(),
show: ({ conflicts, handleDone }) => {
setState({
conflicts,
handleDone,
});
modalRef.current?.show();
},
}), []);
_handleOk() {
// TODO: unsound non-null assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._handleDone!(this.state.conflicts);
const { conflicts, handleDone } = state;
this.hide();
}
_handleToggleSelect(key: DocumentKey, event: React.SyntheticEvent<HTMLInputElement>) {
const conflicts = this.state.conflicts.map(c => {
if (c.key !== key) {
return c;
}
return { ...c, choose: event.currentTarget.value || null };
});
this.setState({
conflicts,
});
}
async show(options: {
conflicts: MergeConflict[];
handleDone: (arg0: MergeConflict[]) => void;
}) {
this.modal?.show();
this._handleDone = options.handleDone;
this.setState({
conflicts: options.conflicts,
});
}
hide() {
this.modal?.hide();
}
render() {
const { conflicts } = this.state;
return (
<Modal ref={this._setModalRef}>
<ModalHeader key="header">Resolve Conflicts</ModalHeader>
<ModalBody key="body" className="pad text-center" noScroll>
<table className="table--fancy table--outlined">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th
style={{
width: '10rem',
}}
>
Choose
</th>
return (
<Modal ref={modalRef}>
<ModalHeader key="header">Resolve Conflicts</ModalHeader>
<ModalBody key="body" className="pad text-center" noScroll>
<table className="table--fancy table--outlined">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th
style={{
width: '10rem',
}}
>
Choose
</th>
</tr>
</thead>
<tbody>
{conflicts?.length && conflicts.map(conflict => (
<tr key={conflict.key}>
<td className="text-left">{conflict.name}</td>
<td className="text-left">{conflict.message}</td>
<td className="no-wrap">
<label className="no-pad">
Mine{' '}
<input
type="radio"
value={conflict.mineBlob || ''}
checked={conflict.choose === conflict.mineBlob}
onChange={event => setState({
...state,
conflicts: conflicts.map(c => c.key !== conflict.key ? c : { ...c, choose: event.target.value || null }),
})}
/>
</label>
<label className="no-pad margin-left">
Theirs{' '}
<input
type="radio"
value={conflict.theirsBlob || ''}
checked={conflict.choose === conflict.theirsBlob}
onChange={event => setState({
...state,
conflicts: conflicts.map(c => c.key !== conflict.key ? c : { ...c, choose: event.target.value || null }),
})}
/>
</label>
</td>
</tr>
</thead>
<tbody>
{conflicts.map(conflict => (
<tr key={conflict.key}>
<td className="text-left">{conflict.name}</td>
<td className="text-left">{conflict.message}</td>
<td className="no-wrap">
<label className="no-pad">
Mine{' '}
<input
type="radio"
value={conflict.mineBlob || ''}
checked={conflict.choose === conflict.mineBlob}
onChange={e => this._handleToggleSelect(conflict.key, e)}
/>
</label>
<label className="no-pad margin-left">
Theirs{' '}
<input
type="radio"
value={conflict.theirsBlob || ''}
checked={conflict.choose === conflict.theirsBlob}
onChange={e => this._handleToggleSelect(conflict.key, e)}
/>
</label>
</td>
</tr>
))}
</tbody>
</table>
</ModalBody>
<ModalFooter>
<button className="btn" onClick={this._handleOk}>
Submit Resolutions
</button>
</ModalFooter>
</Modal>
);
}
}
const mapStateToProps = (state: RootState) => ({
syncItems: selectSyncItems(state),
))}
</tbody>
</table>
</ModalBody>
<ModalFooter>
<button
className="btn"
onClick={() => {
handleDone?.(conflicts);
modalRef.current?.hide();
}}
>
Submit Resolutions
</button>
</ModalFooter>
</Modal >
);
});
export const SyncMergeModal = connect(
mapStateToProps,
null,
null,
{ forwardRef: true },
)(UnconnectedSyncMergeModal);
SyncMergeModal.displayName = 'SyncMergeModal';

View File

@ -1,28 +1,23 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { Fragment, PureComponent, ReactNode } from 'react';
import { connect } from 'react-redux';
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AUTOBIND_CFG } from '../../../common/constants';
import { strings } from '../../../common/strings';
import * as models from '../../../models';
import { BaseModel } from '../../../models';
import type { DocumentKey, Stage, StageEntry, Status } from '../../../sync/types';
import { describeChanges } from '../../../sync/vcs/util';
import { VCS } from '../../../sync/vcs/vcs';
import { RootState } from '../../redux/modules';
import { selectSyncItems } from '../../redux/selectors';
import { IndeterminateCheckbox } from '../base/indeterminate-checkbox';
import { type ModalHandle, Modal } from '../base/modal';
import { type ModalHandle, Modal, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
import { ModalFooter } from '../base/modal-footer';
import { ModalHeader } from '../base/modal-header';
import { Tooltip } from '../tooltip';
type ReduxProps = ReturnType<typeof mapStateToProps>;
interface Props extends ReduxProps {
type Props = ModalProps & {
vcs: VCS;
}
};
type LookupMap = Record<string, {
entry: StageEntry;
@ -31,382 +26,353 @@ type LookupMap = Record<string, {
checked: boolean;
}>;
interface State {
export interface SyncStagingModalOptions {
status: Status;
message: string;
error: string;
branch: string;
lookupMap: LookupMap;
onSnapshot: () => Promise<void>;
handlePush: () => Promise<void>;
}
const _initialState: State = {
status: {
stage: {},
unstaged: {},
key: '',
},
branch: '',
error: '',
message: '',
lookupMap: {},
};
export interface SyncStagingModalHandle {
show: (options: SyncStagingModalOptions) => void;
hide: () => void;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class UnconnectedSyncStagingModal extends PureComponent<Props, State> {
modal: ModalHandle | null = null;
_onSnapshot: (() => void) | null = null;
_handlePush: (() => Promise<void>) | null = null;
textarea: HTMLTextAreaElement | null = null;
state = _initialState;
export const SyncStagingModal = forwardRef<SyncStagingModalHandle, Props>(({ vcs }, ref) => {
const modalRef = useRef<ModalHandle>(null);
const syncItems = useSelector(selectSyncItems);
const [state, setState] = useState<SyncStagingModalOptions>({
status: {
stage: {},
unstaged: {},
key: '',
},
branch: '',
error: '',
message: '',
lookupMap: {},
onSnapshot: async () => { },
handlePush: async () => { },
});
_setModalRef(modal: ModalHandle) {
this.modal = modal;
}
const refreshVCS = useCallback(async (newStage: Stage = {}) => {
const branch = await vcs.getBranch();
const status = await vcs.status(syncItems, newStage);
const lookupMap: LookupMap = {};
const allKeys = [...Object.keys(status.stage), ...Object.keys(status.unstaged)];
for (const key of allKeys) {
const lastSnapshot: BaseModel | null = await vcs.blobFromLastSnapshot(key);
const document = syncItems.find(si => si.key === key)?.document;
const docOrLastSnapshot = document || lastSnapshot;
const entry = status.stage[key] || status.unstaged[key];
const hasStagingChangeAndDoc = entry && docOrLastSnapshot;
const hasDocAndLastSnapshot = document && lastSnapshot;
if (hasStagingChangeAndDoc) {
lookupMap[key] = {
changes: hasDocAndLastSnapshot ? describeChanges(document, lastSnapshot) : null,
entry: entry,
type: models.getModelName(docOrLastSnapshot.type),
checked: !!status.stage[key],
};
}
}
setState(state => ({
...state,
status,
branch,
lookupMap,
error: '',
}));
}, [syncItems, vcs]);
_setTextAreaRef(textarea: HTMLTextAreaElement) {
this.textarea = textarea;
}
useImperativeHandle(ref, () => ({
hide: () => {
modalRef.current?.hide();
},
show: async ({ onSnapshot, handlePush }) => {
modalRef.current?.show();
// Reset state
setState({
status: {
stage: {},
unstaged: {},
key: '',
},
branch: '',
error: '',
message: '',
lookupMap: {},
onSnapshot,
handlePush,
});
// Add everything to stage by default except new items
const status: Status = await vcs.status(syncItems, {});
const toStage: StageEntry[] = [];
for (const key of Object.keys(status.unstaged)) {
if ('added' in status.unstaged[key]) {
// Don't automatically stage added resources
continue;
}
toStage.push(status.unstaged[key]);
}
const stage = await vcs.stage(status.stage, toStage);
await refreshVCS(stage);
},
}), [refreshVCS, syncItems, vcs]);
_handleClearError() {
this.setState({ error: '' });
}
_handleMessageChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
this.setState({ message: event.currentTarget.value });
}
async _handleStageToggle(event: React.SyntheticEvent<HTMLInputElement>) {
const { vcs } = this.props;
const { status } = this.state;
const handleStageToggle = async (event: React.SyntheticEvent<HTMLInputElement>) => {
const { status } = state;
const id = event.currentTarget.name;
const isStaged = !!status.stage[id];
const newStage = isStaged
? await vcs.unstage(status.stage, [status.stage[id]])
: await vcs.stage(status.stage, [status.unstaged[id]]);
await this.refreshMainAttributes({}, newStage);
}
await refreshVCS(newStage);
};
async _handleAllToggle(keys: DocumentKey[], doStage: boolean) {
const { vcs } = this.props;
const { status } = this.state;
const handleAllToggle = async (keys: DocumentKey[], doStage: boolean) => {
const { status } = state;
let stage;
if (doStage) {
const entries: StageEntry[] = [];
for (const k of Object.keys(status.unstaged)) {
if (keys.includes(k)) {
entries.push(status.unstaged[k]);
}
}
stage = await vcs.stage(status.stage, entries);
} else {
const entries: StageEntry[] = [];
for (const k of Object.keys(status.stage)) {
if (keys.includes(k)) {
entries.push(status.stage[k]);
}
}
stage = await vcs.unstage(status.stage, entries);
}
await refreshVCS(stage);
};
await this.refreshMainAttributes({}, stage);
}
async _handleTakeSnapshotAndPush() {
const success = await this._handleTakeSnapshot();
const handleTakeSnapshotAndPush = async () => {
const success = await handleTakeSnapshot();
if (success) {
this._handlePush?.();
state.handlePush?.();
}
}
};
async _handleTakeSnapshot() {
const { vcs } = this.props;
const handleTakeSnapshot = async () => {
const {
message,
status: { stage },
} = this.state;
onSnapshot,
} = state;
try {
await vcs.takeSnapshot(stage, message);
} catch (err) {
this.setState({
setState(state => ({
...state,
error: err.message,
});
}));
return false;
}
this._onSnapshot?.();
await this.refreshMainAttributes({
message: '',
error: '',
});
this.hide();
onSnapshot?.();
await refreshVCS();
setState(state => ({ ...state, message: '', error: '' }));
modalRef.current?.hide();
return true;
}
};
async refreshMainAttributes(newState: Partial<State> = {}, newStage: Stage = {}) {
const { vcs, syncItems } = this.props;
const branch = await vcs.getBranch();
const status = await vcs.status(syncItems, newStage);
const lookupMap: LookupMap = {};
const allKeys = [...Object.keys(status.stage), ...Object.keys(status.unstaged)];
const { status, message, error, branch } = state;
const allMap = { ...status.stage, ...status.unstaged };
const addedKeys: string[] = Object.entries(allMap)
.filter(([, value]) => 'added' in value)
.map(([key]) => key);
const nonAddedKeys: string[] = Object.entries(allMap)
.filter(([, value]) => !('added' in value))
.map(([key]) => key);
for (const key of allKeys) {
const item = syncItems.find(si => si.key === key);
const oldDoc: BaseModel | null = await vcs.blobFromLastSnapshot(key);
const doc = (item && item.document) || oldDoc;
const entry = status.stage[key] || status.unstaged[key];
if (!entry || !doc) {
continue;
}
let changes: string[] | null = null;
if (item && item.document && oldDoc) {
changes = describeChanges(item.document, oldDoc);
}
lookupMap[key] = {
changes,
entry: entry,
type: models.getModelName(doc.type),
checked: !!status.stage[key],
};
}
// @ts-expect-error -- TSCONVERSION
this.setState({
status,
branch,
lookupMap,
error: '',
...newState,
});
}
hide() {
this.modal?.hide();
}
async show(options: { onSnapshot?: () => any; handlePush: () => Promise<void> }) {
const { vcs, syncItems } = this.props;
this.modal?.show();
// @ts-expect-error -- TSCONVERSION
this._onSnapshot = options.onSnapshot;
this._handlePush = options.handlePush;
// Reset state
this.setState(_initialState);
// Add everything to stage by default except new items
const status: Status = await vcs.status(syncItems, {});
const toStage: StageEntry[] = [];
for (const key of Object.keys(status.unstaged)) {
// @ts-expect-error -- TSCONVERSION
if (status.unstaged[key].added) {
// Don't automatically stage added resources
continue;
}
toStage.push(status.unstaged[key]);
}
const stage = await vcs.stage(status.stage, toStage);
await this.refreshMainAttributes({}, stage);
this.textarea?.focus();
}
static renderOperation(entry: StageEntry, type: string, changes: string[]) {
let child: JSX.Element | null = null;
let message = '';
// @ts-expect-error -- TSCONVERSION type narrowing
if (entry.added) {
child = <i className="fa fa-plus-circle success" />;
message = 'Added';
// @ts-expect-error -- TSCONVERSION type narrowing
} else if (entry.modified) {
child = <i className="fa fa-circle faded" />;
message = `Modified (${changes.join(', ')})`;
// @ts-expect-error -- TSCONVERSION type narrowing
} else if (entry.deleted) {
child = <i className="fa fa-minus-circle danger" />;
message = 'Deleted';
} else {
child = <i className="fa fa-question-circle info" />;
message = 'Unknown';
}
if (type === models.workspace.type) {
type = strings.collection.singular;
}
return (
<Fragment>
<Tooltip message={message}>
{child} {type}
</Tooltip>
</Fragment>
);
}
renderTable(keys: DocumentKey[], title: ReactNode) {
const { status, lookupMap } = this.state;
if (keys.length === 0) {
return null;
}
let allUnChecked = true;
let allChecked = true;
for (const key of keys.sort()) {
if (!status.stage[key]) {
allChecked = false;
}
if (!status.unstaged[key]) {
allUnChecked = false;
}
}
const indeterminate = !allChecked && !allUnChecked;
return (
<div className="pad-top">
<strong>{title}</strong>
<table className="table--fancy table--outlined margin-top-sm">
<thead>
<tr>
<th>
<label className="wide no-pad">
<span className="txt-md">
<IndeterminateCheckbox
className="space-right"
checked={allChecked}
onChange={() => this._handleAllToggle(keys, allUnChecked)}
indeterminate={indeterminate}
/>
</span>{' '}
name
</label>
</th>
<th className="text-right ">Changes</th>
<th className="text-right">Description</th>
</tr>
</thead>
<tbody>
{keys.map(key => {
if (!lookupMap[key]) {
return null;
}
const { entry, type, checked, changes } = lookupMap[key];
return (
<tr key={key} className="table--no-outline-row">
<td>
<label className="no-pad wide">
<input
className="space-right"
type="checkbox"
checked={checked}
name={key}
onChange={this._handleStageToggle}
/>{' '}
{entry.name}
</label>
</td>
<td className="text-right">{changes ? changes.join(', ') : '--'}</td>
<td className="text-right">
{SyncStagingModal.renderOperation(entry, type, changes || [])}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
render() {
const { status, message, error, branch } = this.state;
const nonAddedKeys: string[] = [];
const addedKeys: string[] = [];
const allMap = { ...status.stage, ...status.unstaged };
const allKeys = Object.keys(allMap);
for (const key of allKeys) {
// @ts-expect-error -- TSCONVERSION
if (allMap[key].added) {
addedKeys.push(key);
} else {
nonAddedKeys.push(key);
}
}
return (
<Modal ref={this._setModalRef}>
<ModalHeader>Create Snapshot</ModalHeader>
<ModalBody className="wide pad">
{error && (
<p className="notice error margin-bottom-sm no-margin-top">
<button className="pull-right icon" onClick={this._handleClearError}>
<i className="fa fa-times" />
</button>
{error}
</p>
)}
<div className="form-group">
<div className="form-control form-control--outlined">
<label>
Snapshot Message
<textarea
ref={this._setTextAreaRef}
cols={30}
rows={3}
onChange={this._handleMessageChange}
value={message}
placeholder="This is a helpful message that describe the changes made in this snapshot"
required
/>
</label>
</div>
</div>
{this.renderTable(nonAddedKeys, 'Modified Objects')}
{this.renderTable(addedKeys, 'Unversioned Objects')}
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {branch}
</div>
<div>
<button className="btn" onClick={this._handleTakeSnapshot}>
Create
</button>
<button className="btn" onClick={this._handleTakeSnapshotAndPush}>
Create and Push
return (
<Modal ref={modalRef}>
<ModalHeader>Create Snapshot</ModalHeader>
<ModalBody className="wide pad">
{error && (
<p className="notice error margin-bottom-sm no-margin-top">
<button className="pull-right icon" onClick={() => setState(state => ({ ...state, error: '' }))}>
<i className="fa fa-times" />
</button>
{error}
</p>
)}
<div className="form-group">
<div className="form-control form-control--outlined">
<label>
Snapshot Message
<textarea
cols={30}
rows={3}
onChange={event => setState(state => ({ ...state, message: event.target.value }))}
value={message}
placeholder="This is a helpful message that describe the changes made in this snapshot"
required
/>
</label>
</div>
</ModalFooter>
</Modal>
);
}
}
const mapStateToProps = (state: RootState) => ({
syncItems: selectSyncItems(state),
</div>
<ChangesTable
keys={nonAddedKeys}
title='Modified Objects'
status={status}
lookupMap={state.lookupMap}
toggleAll={handleAllToggle}
toggleOne={handleStageToggle}
/>
<ChangesTable
keys={addedKeys}
title='Unversioned Objects'
status={status}
lookupMap={state.lookupMap}
toggleAll={handleAllToggle}
toggleOne={handleStageToggle}
/>
</ModalBody>
<ModalFooter>
<div className="margin-left italic txt-sm">
<i className="fa fa-code-fork" /> {branch}
</div>
<div>
<button className="btn" onClick={handleTakeSnapshot}>
Create
</button>
<button className="btn" onClick={handleTakeSnapshotAndPush}>
Create and Push
</button>
</div>
</ModalFooter>
</Modal>
);
});
export const SyncStagingModal = connect(
mapStateToProps,
null,
null,
{ forwardRef: true },
)(UnconnectedSyncStagingModal);
interface OperationTooltipProps {
entry: StageEntry;
type: string;
changes: string[];
}
const OperationTooltip = ({ entry, type, changes }: OperationTooltipProps) => {
const operationType = type === models.workspace.type ? type = strings.collection.singular : type;
if ('added' in entry) {
return (
<Tooltip message="Added">
<i className="fa fa-plus-circle success" /> {operationType}
</Tooltip>
);
}
if ('modified' in entry) {
return (
<Tooltip message={`Modified (${changes.join(', ')})`}>
<i className="fa fa-circle faded" /> {operationType}
</Tooltip>
);
}
if ('deleted' in entry) {
return (
<Tooltip message="Deleted">
<i className="fa fa-minus-circle danger" /> {operationType}
</Tooltip>
);
}
return (
<Tooltip message="Unknown">
<i className="fa fa-question-circle info" /> {operationType}
</Tooltip>
);
};
interface ChangesTableProps {
keys: DocumentKey[];
title: string;
status: Status;
lookupMap: LookupMap;
toggleAll: (keys: DocumentKey[], doStage: boolean) => void;
toggleOne: (event: React.SyntheticEvent<HTMLInputElement>) => void;
}
const ChangesTable = ({
keys,
title,
status,
lookupMap,
toggleAll,
toggleOne,
}: ChangesTableProps) => {
if (keys.length === 0) {
return null;
}
let allUnChecked = true;
let allChecked = true;
for (const key of keys.sort()) {
if (!status.stage[key]) {
allChecked = false;
}
if (!status.unstaged[key]) {
allUnChecked = false;
}
}
const indeterminate = !allChecked && !allUnChecked;
return (
<div className="pad-top">
<strong>{title}</strong>
<table className="table--fancy table--outlined margin-top-sm">
<thead>
<tr>
<th>
<label className="wide no-pad">
<span className="txt-md">
<IndeterminateCheckbox
className="space-right"
checked={allChecked}
onChange={() => toggleAll(keys, allUnChecked)}
indeterminate={indeterminate}
/>
</span>{' '}
name
</label>
</th>
<th className="text-right ">Changes</th>
<th className="text-right">Description</th>
</tr>
</thead>
<tbody>
{keys.filter(key => lookupMap[key]).map(key => {
const { entry, type, checked, changes } = lookupMap[key];
return (
<tr key={key} className="table--no-outline-row">
<td>
<label className="no-pad wide">
<input
className="space-right"
type="checkbox"
checked={checked}
name={key}
onChange={toggleOne}
/>{' '}
{entry.name}
</label>
</td>
<td className="text-right">{changes ? changes.join(', ') : '--'}</td>
<td className="text-right">
<OperationTooltip
entry={entry}
type={type}
changes={changes || []}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
SyncStagingModal.displayName = 'SyncStagingModal';

View File

@ -1,45 +1,29 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import React, { PureComponent, ReactNode } from 'react';
import { connect } from 'react-redux';
import React, { FC, ReactNode, useState } from 'react';
import { useSelector } from 'react-redux';
import { AUTOBIND_CFG } from '../../common/constants';
import { VCS } from '../../sync/vcs/vcs';
import { RootState } from '../redux/modules';
import { selectActiveProject } from '../redux/selectors';
import { showError } from './modals';
type ReduxProps = ReturnType<typeof mapStateToProps>;
interface Props extends ReduxProps {
interface Props {
vcs: VCS;
branch: string;
onPull: (...args: any[]) => any;
onPull: () => void;
disabled?: boolean;
className?: string;
children?: ReactNode;
}
interface State {
loading: boolean;
}
@autoBindMethodsForReact(AUTOBIND_CFG)
export class UnconnectedSyncPullButton extends PureComponent<Props, State> {
_timeout: NodeJS.Timeout | null = null;
state: State = {
loading: false,
};
async _handleClick() {
const { vcs, onPull, branch, project } = this.props;
this.setState({
loading: true,
});
export const SyncPullButton: FC<Props> = props => {
const { className, children, disabled } = props;
const project = useSelector(selectActiveProject);
const [loading, setLoading] = useState(false);
const onClick = async () => {
const { vcs, onPull, branch } = props;
setLoading(true);
const newVCS = vcs.newInstance();
const oldBranch = await newVCS.getBranch();
let failed = false;
try {
// Clone old VCS so we don't mess anything up while working on other projects
await newVCS.checkout([], branch);
@ -57,44 +41,15 @@ export class UnconnectedSyncPullButton extends PureComponent<Props, State> {
// have to do this hack
await newVCS.checkout([], oldBranch);
}
// Do this a bit later so the loading doesn't seem to stop too early
this._timeout = setTimeout(() => {
this.setState({
loading: false,
});
}, 400);
setLoading(false);
if (!failed) {
onPull?.();
}
}
componentWillUnmount() {
if (this._timeout !== null) {
clearTimeout(this._timeout);
}
}
render() {
const { className, children, disabled } = this.props;
const { loading } = this.state;
return (
<button className={className} onClick={this._handleClick} disabled={disabled}>
{loading && <i className="fa fa-spin fa-refresh space-right" />}
{children || 'Pull'}
</button>
);
}
}
const mapStateToProps = (state: RootState) => ({
project: selectActiveProject(state),
});
export const SyncPullButton = connect(
mapStateToProps,
null,
null,
{ forwardRef: true },
)(UnconnectedSyncPullButton);
};
return (
<button className={className} onClick={onClick} disabled={disabled}>
{loading && <i className="fa fa-spin fa-refresh space-right" />}
{children || 'Pull'}
</button>
);
};

View File

@ -282,11 +282,11 @@ const App = () => {
{activeWorkspace && vcs ? (
<Fragment>
<SyncStagingModal ref={registerModal} vcs={vcs} />
<SyncMergeModal ref={registerModal} vcs={vcs} />
<SyncBranchesModal ref={registerModal} vcs={vcs} />
<SyncDeleteModal ref={registerModal} vcs={vcs} />
<SyncHistoryModal ref={registerModal} vcs={vcs} />
<SyncStagingModal ref={instance => registerModal(instance, 'SyncStagingModal')} vcs={vcs} />
<SyncMergeModal ref={instance => registerModal(instance, 'SyncMergeModal')} />
<SyncBranchesModal ref={instance => registerModal(instance, 'SyncBranchesModal')} vcs={vcs} />
<SyncDeleteModal ref={instance => registerModal(instance, 'SyncDeleteModal')} vcs={vcs} />
<SyncHistoryModal ref={instance => registerModal(instance, 'SyncHistoryModal')} vcs={vcs} />
</Fragment>
) : null}