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
|
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
|
||||||
|
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 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
|
||||||
|
@ -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,
|
||||||
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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')
|
||||||
|
Reference in New Issue
Block a user