Skip to content

Commit

Permalink
Add collapsible element to updater notification to show changelog, op…
Browse files Browse the repository at this point in the history
…en 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 <[email protected]>
  • Loading branch information
franknoirot and pierremtb authored Oct 15, 2024
1 parent 1d3ade1 commit d361bda
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 9 deletions.
2 changes: 1 addition & 1 deletion interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface IElectronAPI {
callback: (value: { version: string }) => void
) => Electron.IpcRenderer
onUpdateDownloaded: (
callback: (value: string) => void
callback: (value: { version: string; releaseNotes: string }) => void
) => Electron.IpcRenderer
onUpdateError: (callback: (value: { error: Error }) => void) => Electron
appRestart: () => void
Expand Down
153 changes: 153 additions & 0 deletions src/components/ToastUpdate.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
42 changes: 38 additions & 4 deletions src/components/ToastUpdate.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import toast from 'react-hot-toast'
import { ActionButton } from './ActionButton'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { Marked } from '@ts-stack/markdown'

export function ToastUpdate({
version,
releaseNotes,
onRestart,
onDismiss,
}: {
version: string
releaseNotes?: string
onRestart: () => void
onDismiss: () => void
}) {
const containsBreakingChanges = releaseNotes
?.toLocaleLowerCase()
.includes('breaking')

return (
<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">
Expand All @@ -19,7 +28,7 @@ export function ToastUpdate({
>
v{version}
</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
start the app. You can view the release notes{' '}
<a
Expand All @@ -32,15 +41,39 @@ export function ToastUpdate({
>
here on GitHub.
</a>
</span>
</p>
</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">
<ActionButton
Element="button"
iconStart={{
icon: 'arrowRotateRight',
}}
name="Restart app now"
name="restart"
onClick={onRestart}
>
Restart app now
Expand All @@ -50,9 +83,10 @@ export function ToastUpdate({
iconStart={{
icon: 'checkmark',
}}
name="Got it"
name="dismiss"
onClick={() => {
toast.dismiss()
onDismiss()
}}
>
Got it
Expand Down
18 changes: 18 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,24 @@ code {
which lets you use them with @apply in your CSS, and get
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,
Expand Down
4 changes: 3 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,17 @@ if (isDesktop()) {
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.`
console.log(message)
toast.custom(
ToastUpdate({
version,
releaseNotes,
onRestart: () => {
window.electron.appRestart()
},
onDismiss: () => {},
}),
{ duration: 30000, id: AUTO_UPDATER_TOAST_ID }
)
Expand Down
5 changes: 4 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,10 @@ app.on('ready', () => {

autoUpdater.on('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', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ const startDeviceFlow = (host: string): Promise<string> =>
ipcRenderer.invoke('startDeviceFlow', host)
const loginWithDeviceFlow = (): Promise<string> =>
ipcRenderer.invoke('loginWithDeviceFlow')
const onUpdateDownloaded = (
callback: (value: { version: string; releaseNotes: string }) => void
) => ipcRenderer.on('update-downloaded', (_event, value) => callback(value))
const onUpdateDownloadStart = (
callback: (value: { version: string }) => void
) => 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) =>
ipcRenderer.on('update-error', (_event, value) => callback(value))
const appRestart = () => ipcRenderer.invoke('app.restart')
Expand Down

0 comments on commit d361bda

Please sign in to comment.