Skip to content

Commit

Permalink
feat: notify AUS studio users of new studio versions (#6893)
Browse files Browse the repository at this point in the history
* WIP

* feat: notify AUS users of new packages

* fix: return early if not auto-update studio

* feat: show toast on every poll

* test: add e2e tests

* fix: use interval for more reliable checks

* fix: put some guards in place to make test less flaky

* refactor: update toast duration and logic

* refactor: streamline Promise.all to modules endpoint

Co-authored-by: Rico Kahler <ricokahler@gmail.com>

* feat: add staging URL

* fix: update durations and add comment

* test: skip versionStatus tests because of flakiness for now

* fix: typo in current packages check

Co-authored-by: Rico Kahler <ricokahler@gmail.com>

* test: restore tests

* fix: update toast language

Co-authored-by: Rune Botten <rbotten@gmail.com>

* fix: update toast button

Co-authored-by: Rune Botten <rbotten@gmail.com>

* fix update toast title

Co-authored-by: Rune Botten <rbotten@gmail.com>

* chore: update tests for new language

* format: update i18n formatting

---------

Co-authored-by: Rico Kahler <ricokahler@gmail.com>
Co-authored-by: Rune Botten <rbotten@gmail.com>
  • Loading branch information
3 people authored Jun 18, 2024
1 parent 6bbf609 commit e9b16c8
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 3 deletions.
7 changes: 6 additions & 1 deletion packages/sanity/src/core/i18n/bundles/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,12 @@ export const studioLocaleStrings = defineLocalesResources('studio', {
'new-document.open-dialog-aria-label': 'Create new document',
/** Title for "Create new document" dialog */
'new-document.title': 'Create new document',

/** More detailed alert text letting user know they have an out-of-date version and should reload */
'package-version.new-package-available.description': 'Simply reload to use the new version.',
/** Label for button that will make the browser reload when users' studio version is out-of-date */
'package-version.new-package-available.reload-button': 'Reload',
/** Title of the alert for studio users when packages in their studio are out-of-date */
'package-version.new-package-available.title': 'Sanity Studio was updated',
/** Label for action to manage members of the current studio project */
'presence.action.manage-members': 'Manage members',
/** Accessibility label for presence menu button */
Expand Down
5 changes: 4 additions & 1 deletion packages/sanity/src/core/studio/StudioProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {ActiveWorkspaceMatcher} from './activeWorkspaceMatcher'
import {AuthBoundary} from './AuthBoundary'
import {ColorSchemeProvider} from './colorScheme'
import {Z_OFFSET} from './constants'
import {PackageVersionStatusProvider} from './packageVersionStatus/PackageVersionStatusProvider'
import {
AuthenticateScreen,
ConfigErrorsScreen,
Expand Down Expand Up @@ -64,7 +65,9 @@ export function StudioProvider({
<WorkspaceLoader LoadingComponent={LoadingBlock} ConfigErrorsComponent={ConfigErrorsScreen}>
<StudioTelemetryProvider config={config}>
<LocaleProvider>
<ResourceCacheProvider>{children}</ResourceCacheProvider>
<PackageVersionStatusProvider>
<ResourceCacheProvider>{children}</ResourceCacheProvider>
</PackageVersionStatusProvider>
</LocaleProvider>
</StudioTelemetryProvider>
</WorkspaceLoader>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {Box, Button, Stack, useToast} from '@sanity/ui'
import {type ReactNode, useCallback, useEffect, useRef} from 'react'
import {SANITY_VERSION} from 'sanity'
import semver from 'semver'

import {hasSanityPackageInImportMap} from '../../environment/hasSanityPackageInImportMap'
import {useTranslation} from '../../i18n'
import {checkForLatestVersions} from './checkForLatestVersions'

// How often to to check last timestamp. at 30 min, should fetch new version
const REFRESH_INTERVAL = 1000 * 30 // every 30 seconds
const SHOW_TOAST_FREQUENCY = 1000 * 60 * 30 //half hour

const currentPackageVersions: Record<string, string> = {
sanity: SANITY_VERSION,
}

export function PackageVersionStatusProvider({children}: {children: ReactNode}) {
const toast = useToast()
const {t} = useTranslation()
const lastCheckedTimeRef = useRef<number | null>(null)

const autoUpdatingPackages = hasSanityPackageInImportMap()

const showNewPackageAvailableToast = useCallback(() => {
const onClick = () => {
window.location.reload()
}

toast.push({
id: 'new-package-available',
title: t('package-version.new-package-available.title'),
description: (
<Stack space={2} paddingBottom={2}>
<Box>{t('package-version.new-package-available.description')}</Box>
<Box>
<Button
aria-label={t('package-version.new-package-available.reload-button')}
onClick={onClick}
text={t('package-version.new-package-available.reload-button')}
tone={'primary'}
/>
</Box>
</Stack>
),
closable: true,
status: 'info',
/*
* We want to show the toast until the user closes it.
* Because of the toast ID, we should never see it twice.
* https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value
*/
duration: 1000 * 60 * 60 * 24 * 24,
})
}, [toast, t])

useEffect(() => {
if (!autoUpdatingPackages) return undefined

const fetchLatestVersions = () => {
if (
lastCheckedTimeRef.current &&
lastCheckedTimeRef.current + SHOW_TOAST_FREQUENCY > Date.now()
) {
return
}

checkForLatestVersions(currentPackageVersions).then((latestPackageVersions) => {
lastCheckedTimeRef.current = Date.now()

if (!latestPackageVersions) return

const foundNewVersion = Object.entries(latestPackageVersions).some(([pkg, version]) => {
if (!version || !currentPackageVersions[pkg]) return false
return semver.gt(version, currentPackageVersions[pkg])
})

if (foundNewVersion) {
showNewPackageAvailableToast()
}
})
}

// Run on first render
fetchLatestVersions()

// Set interval for subsequent runs
const intervalId = setInterval(fetchLatestVersions, REFRESH_INTERVAL)

return () => clearInterval(intervalId)
}, [autoUpdatingPackages, showNewPackageAvailableToast])

return <>{children}</>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//object like {sanity: '3.40.1'}
interface VersionMap {
[key: string]: string | undefined
}

//e2e tests also check for this URL pattern -- please update if it changes!
const MODULES_URL_VERSION = 'v1'
const MODULES_HOST =
process.env.SANITY_INTERNAL_ENV === 'staging'
? 'https://sanity-cdn.work'
: 'https://sanity-cdn.com'
const MODULES_URL = `${MODULES_HOST}/${MODULES_URL_VERSION}/modules/`

const fetchLatestVersionForPackage = async (pkg: string, version: string) => {
try {
const res = await fetch(`${MODULES_URL}${pkg}/default/^${version}`, {
headers: {
accept: 'application/json',
},
})
return res.json().then((data) => data.packageVersion)
} catch (err) {
console.error('Failed to fetch latest version for package', pkg, 'Error:', err)
return undefined
}
}

/*
*
*/
export const checkForLatestVersions = async (
packages: Record<string, string>,
): Promise<VersionMap | undefined> => {
const packageNames = Object.keys(packages)

const results = await Promise.all(
Object.entries(packages).map(async ([pkg, version]) => [
pkg,
await fetchLatestVersionForPackage(pkg, version),
]),
)
const packageVersions: VersionMap = Object.fromEntries(results)
return packageVersions
}
1 change: 0 additions & 1 deletion packages/sanity/src/core/version.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {version} from '../../package.json'

/**
* This version is provided by `@sanity/pkg-utils` at build time
* @hidden
Expand Down
61 changes: 61 additions & 0 deletions test/e2e/tests/default-layout/versionStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {expect} from '@playwright/test'
import {test} from '@sanity/test'

//non-updating studio case
test('should not show package version toast if not in auto-updating studio', async ({
page,
baseURL,
}) => {
await page.goto(baseURL ?? '')

await expect(page.getByText('Sanity Studio was updated')).not.toBeVisible()
})

test.describe('auto-updating studio behavior', () => {
test.beforeEach(async ({page, baseURL}) => {
await page.goto(baseURL ?? '', {waitUntil: 'domcontentloaded'})
// Inject a script tag with importmap into the page
await page.evaluate(() => {
const importMap = {
imports: {
sanity: 'https://sanity-cdn.com/v1/modules/sanity/default/example.js',
},
}
const script = document.createElement('script')
script.type = 'importmap'
script.textContent = JSON.stringify(importMap)
document.head.appendChild(script)
})
})

test('should facilitate reload if in auto-updating studio, and version is higher', async ({
page,
}) => {
// Intercept the API request and provide a mock response
await page.route('https://sanity-cdn.**/v1/modules/sanity/default/**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
packageVersion: '3.1000.0',
}),
})
})
await expect(page.getByText('Sanity Studio was updated')).toBeVisible()
})

test('should show nothing if in auto-updating studio, and version is lower', async ({page}) => {
// Intercept the API request and provide a mock response
await page.route('https://sanity-cdn.**/v1/modules/sanity/default/**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
packageVersion: '3.0.0',
}),
})
})

await expect(page.getByText('Sanity Studio was updated')).not.toBeVisible()
})
})

0 comments on commit e9b16c8

Please sign in to comment.