forked from microsoft/BotFramework-Composer
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Implemented Auto-Update Electron UX (microsoft#2721)
* Started stubbing out auto updater. * Added logging. * Fixed build scripts. * Working rough draft of app update UX * More auto update UX polish. * More styling & visual polish. * Added client tests. * Added tests and more polish. * Fixed tests. * Linting fixes. * More linting fixes. * Addressing PR comments * Small settings tweak. Co-authored-by: Chris Whitten <christopher.whitten@microsoft.com>
- Loading branch information
1 parent
fa2e3b1
commit 06f5af6
Showing
25 changed files
with
901 additions
and
28 deletions.
There are no files selected for viewing
120 changes: 120 additions & 0 deletions
120
Composer/packages/client/__tests__/components/appUpdater.test.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.`); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
246 changes: 246 additions & 0 deletions
246
Composer/packages/client/src/components/AppUpdater/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) => { | ||
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; | ||
}; |
Oops, something went wrong.