Add collapsible element to updater notification to show changelog, open if there breaking changes (#4051)

* Add collapsible element to updater toast notification showing release notes

* Temp create release artifacts to test updater

* Fix tsc error

* Fix some styling, make release notes not appear if no notes are present

* Add component tests

* Remove test release builds

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
This commit is contained in:
Frank Noirot
2024-10-15 07:30:00 -04:00
committed by GitHub
parent 1d3ade114f
commit d361bda180
7 changed files with 220 additions and 9 deletions

2
interface.d.ts vendored
View File

@ -73,7 +73,7 @@ export interface IElectronAPI {
callback: (value: { version: string }) => void callback: (value: { version: string }) => void
) => Electron.IpcRenderer ) => Electron.IpcRenderer
onUpdateDownloaded: ( onUpdateDownloaded: (
callback: (value: string) => void callback: (value: { version: string; releaseNotes: string }) => void
) => Electron.IpcRenderer ) => Electron.IpcRenderer
onUpdateError: (callback: (value: { error: Error }) => void) => Electron onUpdateError: (callback: (value: { error: Error }) => void) => Electron
appRestart: () => void appRestart: () => void

View File

@ -0,0 +1,153 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { ToastUpdate } from './ToastUpdate'
describe('ToastUpdate tests', () => {
const testData = {
version: '0.255.255',
files: [
{
url: 'Zoo Modeling App-0.255.255-x64-mac.zip',
sha512:
'VJb0qlrqNr+rVx3QLATz+B28dtHw3osQb5/+UUmQUIMuF9t0i8dTKOVL/2lyJSmLJVw2/SGDB4Ud6VlTPJ6oFw==',
size: 141277345,
},
{
url: 'Zoo Modeling App-0.255.255-arm64-mac.zip',
sha512:
'b+ugdg7A4LhYYJaFkPRxh1RvmGGMlPJJj7inkLg9PwRtCnR9ePMlktj2VRciXF1iLh59XW4bLc4dK1dFQHMULA==',
size: 135278259,
},
{
url: 'Zoo Modeling App-0.255.255-x64-mac.dmg',
sha512:
'gCUqww05yj8OYwPiTq6bo5GbkpngSbXGtenmDD7+kUm0UyVK8WD3dMAfQJtGNG5HY23aHCHe9myE2W4mbZGmiQ==',
size: 146004232,
},
{
url: 'Zoo Modeling App-0.255.255-arm64-mac.dmg',
sha512:
'ND871ayf81F1ZT+iWVLYTc2jdf/Py6KThuxX2QFWz14ebmIbJPL07lNtxQOexOFiuk0MwRhlCy1RzOSG1b9bmw==',
size: 140021522,
},
],
path: 'Zoo Modeling App-0.255.255-x64-mac.zip',
sha512:
'VJb0qlrqNr+rVx3QLATz+B28dtHw3osQb5/+UUmQUIMuF9t0i8dTKOVL/2lyJSmLJVw2/SGDB4Ud6VlTPJ6oFw==',
releaseNotes:
'## Some markdown release notes\n\n- This is a list item\n- This is another list item\n\n```javascript\nconsole.log("Hello, world!")\n```\n',
releaseDate: '2024-10-09T11:57:59.133Z',
} as const
test('Happy path: renders the toast with good data', () => {
const onRestart = vi.fn()
const onDismiss = vi.fn()
render(
<ToastUpdate
onRestart={onRestart}
onDismiss={onDismiss}
version={testData.version}
releaseNotes={testData.releaseNotes}
/>
)
// Locators and other constants
const versionText = screen.getByTestId('update-version')
const restartButton = screen.getByRole('button', { name: /restart/i })
const dismissButton = screen.getByRole('button', { name: /got it/i })
const releaseNotes = screen.getByTestId('release-notes')
expect(versionText).toBeVisible()
expect(versionText).toHaveTextContent(testData.version)
expect(restartButton).toBeEnabled()
fireEvent.click(restartButton)
expect(onRestart.mock.calls).toHaveLength(1)
expect(dismissButton).toBeEnabled()
fireEvent.click(dismissButton)
expect(onDismiss.mock.calls).toHaveLength(1)
// I cannot for the life of me seem to get @testing-library/react
// to properly handle click events or visibility checks on the details element.
// So I'm only checking that the content is in the document.
expect(releaseNotes).toBeInTheDocument()
expect(releaseNotes).toHaveTextContent('Release notes')
const releaseNotesListItems = screen.getAllByRole('listitem')
expect(releaseNotesListItems.map((el) => el.textContent)).toEqual([
'This is a list item',
'This is another list item',
])
})
test('Happy path: renders the breaking changes notice', () => {
const releaseNotesWithBreakingChanges = `
## Some markdown release notes
- This is a list item
- This is another list item with a breaking change
- This is a list item
`
const onRestart = vi.fn()
const onDismiss = vi.fn()
render(
<ToastUpdate
onRestart={onRestart}
onDismiss={onDismiss}
version={testData.version}
releaseNotes={releaseNotesWithBreakingChanges}
/>
)
// Locators and other constants
const releaseNotes = screen.getByText('Release notes', {
selector: 'summary',
})
const listItemContents = screen
.getAllByRole('listitem')
.map((el) => el.textContent)
// I cannot for the life of me seem to get @testing-library/react
// to properly handle click events or visibility checks on the details element.
// So I'm only checking that the content is in the document.
expect(releaseNotes).toBeInTheDocument()
expect(listItemContents).toEqual([
'This is a list item',
'This is another list item with a breaking change',
'This is a list item',
])
})
test('Missing release notes: renders the toast without release notes', () => {
const onRestart = vi.fn()
const onDismiss = vi.fn()
render(
<ToastUpdate
onRestart={onRestart}
onDismiss={onDismiss}
version={testData.version}
releaseNotes={''}
/>
)
// Locators and other constants
const versionText = screen.getByTestId('update-version')
const restartButton = screen.getByRole('button', { name: /restart/i })
const dismissButton = screen.getByRole('button', { name: /got it/i })
const releaseNotes = screen.queryByText(/release notes/i, {
selector: 'details > summary',
})
const releaseNotesListItem = screen.queryByRole('listitem', {
name: /this is a list item/i,
})
expect(versionText).toBeVisible()
expect(versionText).toHaveTextContent(testData.version)
expect(releaseNotes).not.toBeInTheDocument()
expect(releaseNotesListItem).not.toBeInTheDocument()
expect(restartButton).toBeEnabled()
expect(dismissButton).toBeEnabled()
})
})

View File

@ -1,14 +1,23 @@
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { openExternalBrowserIfDesktop } from 'lib/openWindow' import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { Marked } from '@ts-stack/markdown'
export function ToastUpdate({ export function ToastUpdate({
version, version,
releaseNotes,
onRestart, onRestart,
onDismiss,
}: { }: {
version: string version: string
releaseNotes?: string
onRestart: () => void onRestart: () => void
onDismiss: () => void
}) { }) {
const containsBreakingChanges = releaseNotes
?.toLocaleLowerCase()
.includes('breaking')
return ( return (
<div className="inset-0 z-50 grid place-content-center rounded bg-chalkboard-110/50 shadow-md"> <div className="inset-0 z-50 grid place-content-center rounded bg-chalkboard-110/50 shadow-md">
<div className="max-w-3xl min-w-[35rem] p-8 rounded bg-chalkboard-10 dark:bg-chalkboard-90"> <div className="max-w-3xl min-w-[35rem] p-8 rounded bg-chalkboard-10 dark:bg-chalkboard-90">
@ -19,7 +28,7 @@ export function ToastUpdate({
> >
v{version} v{version}
</span> </span>
<span className="ml-4 text-md text-bold"> <p className="ml-4 text-md text-bold">
A new update has downloaded and will be available next time you A new update has downloaded and will be available next time you
start the app. You can view the release notes{' '} start the app. You can view the release notes{' '}
<a <a
@ -32,15 +41,39 @@ export function ToastUpdate({
> >
here on GitHub. here on GitHub.
</a> </a>
</span> </p>
</div> </div>
{releaseNotes && (
<details
className="my-4 border border-chalkboard-30 dark:border-chalkboard-60 rounded"
open={containsBreakingChanges}
data-testid="release-notes"
>
<summary className="p-2 select-none cursor-pointer">
Release notes
{containsBreakingChanges && (
<strong className="text-destroy-50"> (Breaking changes)</strong>
)}
</summary>
<div
className="parsed-markdown py-2 px-4 mt-2 border-t border-chalkboard-30 dark:border-chalkboard-60 max-h-60 overflow-y-auto"
dangerouslySetInnerHTML={{
__html: Marked.parse(releaseNotes, {
gfm: true,
breaks: true,
sanitize: true,
}),
}}
></div>
</details>
)}
<div className="flex justify-between gap-8"> <div className="flex justify-between gap-8">
<ActionButton <ActionButton
Element="button" Element="button"
iconStart={{ iconStart={{
icon: 'arrowRotateRight', icon: 'arrowRotateRight',
}} }}
name="Restart app now" name="restart"
onClick={onRestart} onClick={onRestart}
> >
Restart app now Restart app now
@ -50,9 +83,10 @@ export function ToastUpdate({
iconStart={{ iconStart={{
icon: 'checkmark', icon: 'checkmark',
}} }}
name="Got it" name="dismiss"
onClick={() => { onClick={() => {
toast.dismiss() toast.dismiss()
onDismiss()
}} }}
> >
Got it Got it

View File

@ -293,6 +293,24 @@ code {
which lets you use them with @apply in your CSS, and get which lets you use them with @apply in your CSS, and get
autocomplete in classNames in your JSX. autocomplete in classNames in your JSX.
*/ */
.parsed-markdown ul,
.parsed-markdown ol {
@apply list-outside pl-4 lg:pl-8 my-2;
}
.parsed-markdown ul li {
@apply list-disc;
}
.parsed-markdown li p {
@apply inline;
}
.parsed-markdown code {
@apply px-1 py-0.5 rounded-sm;
@apply bg-chalkboard-20 text-chalkboard-80;
@apply dark:bg-chalkboard-80 dark:text-chalkboard-30;
}
} }
#code-mirror-override .cm-scroller, #code-mirror-override .cm-scroller,

View File

@ -70,15 +70,17 @@ if (isDesktop()) {
id: AUTO_UPDATER_TOAST_ID, id: AUTO_UPDATER_TOAST_ID,
}) })
}) })
window.electron.onUpdateDownloaded((version: string) => { window.electron.onUpdateDownloaded(({ version, releaseNotes }) => {
const message = `A new update (${version}) was downloaded and will be available next time you open the app.` const message = `A new update (${version}) was downloaded and will be available next time you open the app.`
console.log(message) console.log(message)
toast.custom( toast.custom(
ToastUpdate({ ToastUpdate({
version, version,
releaseNotes,
onRestart: () => { onRestart: () => {
window.electron.appRestart() window.electron.appRestart()
}, },
onDismiss: () => {},
}), }),
{ duration: 30000, id: AUTO_UPDATER_TOAST_ID } { duration: 30000, id: AUTO_UPDATER_TOAST_ID }
) )

View File

@ -287,7 +287,10 @@ app.on('ready', () => {
autoUpdater.on('update-downloaded', (info) => { autoUpdater.on('update-downloaded', (info) => {
console.log('update-downloaded', info) console.log('update-downloaded', info)
mainWindow?.webContents.send('update-downloaded', info.version) mainWindow?.webContents.send('update-downloaded', {
version: info.version,
releaseNotes: info.releaseNotes,
})
}) })
ipcMain.handle('app.restart', () => { ipcMain.handle('app.restart', () => {

View File

@ -16,11 +16,12 @@ const startDeviceFlow = (host: string): Promise<string> =>
ipcRenderer.invoke('startDeviceFlow', host) ipcRenderer.invoke('startDeviceFlow', host)
const loginWithDeviceFlow = (): Promise<string> => const loginWithDeviceFlow = (): Promise<string> =>
ipcRenderer.invoke('loginWithDeviceFlow') ipcRenderer.invoke('loginWithDeviceFlow')
const onUpdateDownloaded = (
callback: (value: { version: string; releaseNotes: string }) => void
) => ipcRenderer.on('update-downloaded', (_event, value) => callback(value))
const onUpdateDownloadStart = ( const onUpdateDownloadStart = (
callback: (value: { version: string }) => void callback: (value: { version: string }) => void
) => ipcRenderer.on('update-download-start', (_event, value) => callback(value)) ) => ipcRenderer.on('update-download-start', (_event, value) => callback(value))
const onUpdateDownloaded = (callback: (value: string) => void) =>
ipcRenderer.on('update-downloaded', (_event, value) => callback(value))
const onUpdateError = (callback: (value: Error) => void) => const onUpdateError = (callback: (value: Error) => void) =>
ipcRenderer.on('update-error', (_event, value) => callback(value)) ipcRenderer.on('update-error', (_event, value) => callback(value))
const appRestart = () => ipcRenderer.invoke('app.restart') const appRestart = () => ipcRenderer.invoke('app.restart')