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:
2
interface.d.ts
vendored
2
interface.d.ts
vendored
@ -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
|
||||
|
153
src/components/ToastUpdate.test.tsx
Normal file
153
src/components/ToastUpdate.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
@ -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">
|
||||
@ -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
|
||||
@ -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
|
||||
@ -50,9 +83,10 @@ export function ToastUpdate({
|
||||
iconStart={{
|
||||
icon: 'checkmark',
|
||||
}}
|
||||
name="Got it"
|
||||
name="dismiss"
|
||||
onClick={() => {
|
||||
toast.dismiss()
|
||||
onDismiss()
|
||||
}}
|
||||
>
|
||||
Got it
|
||||
|
@ -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,
|
||||
|
@ -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 }
|
||||
)
|
||||
|
@ -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', () => {
|
||||
|
@ -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')
|
||||
|
Reference in New Issue
Block a user