Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implemented Auto-Update Electron UX #2721

Merged
merged 16 commits into from
Apr 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions Composer/packages/client/__tests__/components/appUpdater.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import React from 'react';
import { render } from '@bfc/test-utils';

import { StoreContext } from '../../src/store';
import { AppUpdater } from '../../src/components/AppUpdater';
import { AppUpdaterStatus } from '../../src/constants';

describe('<AppUpdater />', () => {
let storeContext;
beforeEach(() => {
storeContext = {
actions: {},
state: {
appUpdate: {
progressPercent: 0,
showing: true,
status: AppUpdaterStatus.IDLE,
},
},
};
});

it('should not render anything when the modal is set to hidden', () => {
storeContext.state.appUpdate.showing = false;
const { container } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
expect(container.firstChild).toBeFalsy();
});

it('should not render anything when the modal is set to hidden (even when not idle)', () => {
storeContext.state.appUpdate.showing = false;
storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_UNAVAILABLE;
const { container } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
expect(container.firstChild).toBeFalsy();
});

it('should render the update available dialog', () => {
storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_AVAILABLE;
storeContext.state.appUpdate.version = '1.0.0';
const { getByText } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
getByText('New update available');
getByText('Bot Framework Composer v1.0.0');
getByText('Install the update and restart Composer.');
getByText('Download the new version manually.');
});

it('should render the update unavailable dialog', () => {
storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_UNAVAILABLE;
const { getByText } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
getByText('No updates available');
getByText('Composer is up to date.');
});

it('should render the update completed dialog', () => {
storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_SUCCEEDED;
const { getByText } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
getByText('Update complete');
getByText('Composer will restart.');
});

it('should render the update in progress dialog (before total size in known)', () => {
storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_IN_PROGRESS;
const { getByText } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
getByText('Update in progress');
getByText('Downloading...');
getByText('0% of Calculating...');
});

it('should render the update in progress dialog', () => {
storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_IN_PROGRESS;
storeContext.state.appUpdate.progressPercent = 23;
storeContext.state.appUpdate.downloadSizeInBytes = 14760000;
const { getByText } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
getByText('Update in progress');
getByText('Downloading...');
getByText('23% of 14.76MB');
});

it('should render the error dialog', () => {
storeContext.state.appUpdate.status = AppUpdaterStatus.UPDATE_FAILED;
storeContext.state.appUpdate.error = '408 Request timed out.';
const { getByText } = render(
<StoreContext.Provider value={storeContext}>
<AppUpdater />
</StoreContext.Provider>
);
getByText('Update failed');
getByText(`Couldn't complete the update: 408 Request timed out.`);
});
});
3 changes: 3 additions & 0 deletions Composer/packages/client/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ expect.extend({
};
},
});

// for tests using Electron IPC to talk to main process
(window as any).ipcRenderer = { on: jest.fn() };
a-b-r-o-w-n marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions Composer/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { CreationFlow } from './CreationFlow';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RequireAuth } from './components/RequireAuth';
import { CreationFlowStatus } from './constants';
import { AppUpdater } from './components/AppUpdater';

initializeIcons(undefined, { disableWarnings: true });

Expand Down Expand Up @@ -209,6 +210,7 @@ export const App: React.FC = () => {
</ErrorBoundary>
</div>
<Suspense fallback={<div />}>{!state.onboarding.complete && <Onboarding />}</Suspense>
{(window as any).__IS_ELECTRON__ && <AppUpdater />}
</div>
</Fragment>
);
Expand Down
246 changes: 246 additions & 0 deletions Composer/packages/client/src/components/AppUpdater/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React, { useContext, useEffect, useMemo, useState, useCallback } from 'react';
import { Dialog, DialogFooter, DialogType } from 'office-ui-fabric-react/lib/Dialog';
import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import { ChoiceGroup } from 'office-ui-fabric-react/lib/ChoiceGroup';
import formatMessage from 'format-message';

import { StoreContext } from '../../store';
import { AppUpdaterStatus } from '../../constants';

import { dialogContent, dialogCopy, dialogFooter, optionRoot, optionIcon, updateAvailableDismissBtn } from './styles';

const { ipcRenderer } = window as any;

function SelectOption(props) {
const { checked, text, key } = props;
return (
<div key={key} css={optionRoot}>
<Icon iconName={checked ? 'RadioBtnOn' : 'RadioBtnOff'} css={optionIcon(checked)} />
<span>{text}</span>
</div>
);
}

const downloadOptions = {
downloadOnly: 'downloadOnly',
installAndUpdate: 'installAndUpdate',
};

export const AppUpdater: React.FC<{}> = _props => {
const {
actions: { setAppUpdateError, setAppUpdateProgress, setAppUpdateShowing, setAppUpdateStatus },
state: { appUpdate },
} = useContext(StoreContext);
const { downloadSizeInBytes, error, progressPercent, showing, status, version } = appUpdate;
const [downloadOption, setDownloadOption] = useState(downloadOptions.installAndUpdate);

const handleDismiss = useCallback(() => {
setAppUpdateShowing(false);
if (status === AppUpdaterStatus.UPDATE_UNAVAILABLE || status === AppUpdaterStatus.UPDATE_FAILED) {
setAppUpdateStatus({ status: AppUpdaterStatus.IDLE, version: undefined });
}
}, [showing, status]);

const handlePreDownloadOkay = useCallback(() => {
// notify main to download the update
setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_IN_PROGRESS });
ipcRenderer.send('app-update', 'start-download');
}, []);

const handlePostDownloadOkay = useCallback(() => {
setAppUpdateShowing(false);
if (downloadOption === downloadOptions.installAndUpdate) {
ipcRenderer.send('app-update', 'install-update');
}
}, [downloadOption]);

const handleDownloadOptionChange = useCallback((_ev, option) => {
setDownloadOption(option);
}, []);

// listen for app updater events from main process
useEffect(() => {
ipcRenderer.on('app-update', (_event, name, payload) => {
tonyanziano marked this conversation as resolved.
Show resolved Hide resolved
switch (name) {
case 'update-available':
setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_AVAILABLE, version: payload.version });
setAppUpdateShowing(true);
break;

case 'progress': {
const progress = (payload.percent as number).toFixed(2);
setAppUpdateProgress({ progressPercent: progress, downloadSizeInBytes: payload.total });
break;
}

case 'update-not-available':
// TODO: re-enable once we have implemented explicit "check for updates"
// setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_UNAVAILABLE });
// setAppUpdateShowing(true);
break;

case 'update-downloaded':
setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_SUCCEEDED });
setAppUpdateShowing(true);
break;

case 'error':
setAppUpdateStatus({ status: AppUpdaterStatus.UPDATE_FAILED });
setAppUpdateError(payload);
setAppUpdateShowing(true);
break;

default:
break;
}
});
}, []);

const title = useMemo(() => {
switch (status) {
case AppUpdaterStatus.UPDATE_AVAILABLE:
return formatMessage('New update available');

case AppUpdaterStatus.UPDATE_FAILED:
return formatMessage('Update failed');

case AppUpdaterStatus.UPDATE_IN_PROGRESS:
return formatMessage('Update in progress');

case AppUpdaterStatus.UPDATE_SUCCEEDED:
return formatMessage('Update complete');

case AppUpdaterStatus.UPDATE_UNAVAILABLE:
return formatMessage('No updates available');

case AppUpdaterStatus.IDLE:
return '';

default:
return '';
}
}, [status]);

const content = useMemo(() => {
switch (status) {
case AppUpdaterStatus.UPDATE_AVAILABLE:
return (
<ChoiceGroup
defaultSelectedKey={downloadOptions.installAndUpdate}
options={[
{
key: downloadOptions.installAndUpdate,
text: formatMessage('Install the update and restart Composer.'),
onRenderField: SelectOption,
},
{
key: downloadOptions.downloadOnly,
text: formatMessage('Download the new version manually.'),
onRenderField: SelectOption,
},
]}
onChange={handleDownloadOptionChange}
required={true}
/>
);

case AppUpdaterStatus.UPDATE_FAILED:
return <p css={dialogCopy}>{`${formatMessage(`Couldn't complete the update:`)} ${error}`}</p>;

case AppUpdaterStatus.UPDATE_IN_PROGRESS: {
let trimmedTotalInMB;
if (downloadSizeInBytes === undefined) {
trimmedTotalInMB = 'Calculating...';
} else {
trimmedTotalInMB = `${((downloadSizeInBytes || 0) / 1000000).toFixed(2)}MB`;
}
const progressInHundredths = (progressPercent || 0) / 100;
return (
<ProgressIndicator
label={formatMessage('Downloading...')}
description={`${progressPercent}% ${formatMessage('of')} ${trimmedTotalInMB}`}
percentComplete={progressInHundredths}
/>
);
}

case AppUpdaterStatus.UPDATE_SUCCEEDED: {
const text =
downloadOption === downloadOptions.installAndUpdate
? formatMessage('Composer will restart.')
: formatMessage('Composer will update the next time you start the app.');
return <p css={dialogCopy}>{text}</p>;
}

case AppUpdaterStatus.UPDATE_UNAVAILABLE:
return <p css={dialogCopy}>{formatMessage('Composer is up to date.')}</p>;

case AppUpdaterStatus.IDLE:
return undefined;

default:
return undefined;
}
}, [status, progressPercent]);

const footer = useMemo(() => {
switch (status) {
case AppUpdaterStatus.UPDATE_AVAILABLE:
return (
<div>
<DefaultButton onClick={handleDismiss} styles={updateAvailableDismissBtn} text={formatMessage('Cancel')} />
<PrimaryButton onClick={handlePreDownloadOkay} text={formatMessage('Okay')} />
</div>
);

case AppUpdaterStatus.UPDATE_SUCCEEDED:
return <PrimaryButton onClick={handlePostDownloadOkay} text={formatMessage('Okay')} />;

case AppUpdaterStatus.UPDATE_FAILED:
return <PrimaryButton onClick={handleDismiss} text={formatMessage('Okay')} />;

case AppUpdaterStatus.UPDATE_UNAVAILABLE:
return <PrimaryButton onClick={handleDismiss} text={formatMessage('Okay')} />;

case AppUpdaterStatus.UPDATE_IN_PROGRESS:
return undefined;

case AppUpdaterStatus.IDLE:
return undefined;

default:
return undefined;
}
}, [status]);

const subText =
status === AppUpdaterStatus.UPDATE_AVAILABLE ? `${formatMessage('Bot Framework Composer')} v${version}` : '';

return showing ? (
<Dialog
hidden={false}
onDismiss={handleDismiss}
dialogContentProps={{
styles: dialogContent,
subText: subText,
type: DialogType.close,
title,
}}
minWidth={427}
maxWidth={427}
modalProps={{
isBlocking: false,
}}
>
{content}
<DialogFooter styles={dialogFooter}>{footer}</DialogFooter>
</Dialog>
) : null;
};
Loading