From 61799cf0afd58f1950ec52cf854622f262e537ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:49:10 +1000 Subject: [PATCH 1/3] chore(deps): bump multer from 1.4.5-lts.1 to 2.2.0 (#7079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [multer](https://github.com/expressjs/multer) from 1.4.5-lts.1 to 2.2.0.
Release notes

Sourced from multer's releases.

v2.2.0

Important

What's Changed

New Contributors

Full Changelog: https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0

v2.1.1

Important

What's Changed

New Contributors

Full Changelog: https://github.com/expressjs/multer/compare/v2.1.0...v2.1.1

v2.1.0

Important

What's Changed

... (truncated)

Changelog

Sourced from multer's changelog.

2.2.0

2.1.1

2.1.0

2.0.2

2.0.1

2.0.0

1.4.5-lts.2

Commits
Maintainer changes

This version was pushed to npm by ulisesgascon, a new releaser for multer since your current version.


> **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: kulesy --- pnpm-lock.yaml | 86 ++++++++------------------------------------- pnpm-workspace.yaml | 2 +- 2 files changed, 15 insertions(+), 73 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6426f89acd..9ce7a5f356 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,8 +511,8 @@ catalogs: specifier: ^0.0.4 version: 0.0.4 multer: - specifier: 1.4.5-lts.1 - version: 1.4.5-lts.1 + specifier: 2.2.0 + version: 2.2.0 normalize-path: specifier: ^3.0.0 version: 3.0.0 @@ -2107,7 +2107,7 @@ importers: version: 1.41.3 multer: specifier: 'catalog:' - version: 1.4.5-lts.1 + version: 2.2.0 devDependencies: '@tinacms/scripts': specifier: workspace:* @@ -2144,7 +2144,7 @@ importers: version: 3.952.0 multer: specifier: 'catalog:' - version: 1.4.5-lts.1 + version: 2.2.0 devDependencies: '@tinacms/scripts': specifier: workspace:* @@ -9829,9 +9829,9 @@ packages: engines: {node: '>=10.8.0'} hasBin: true - concat-stream@1.6.2: - resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} - engines: {'0': node >= 0.8} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -9887,9 +9887,6 @@ packages: core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -11751,9 +11748,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -13167,10 +13161,6 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -13223,10 +13213,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - multer@1.4.5-lts.1: - resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} - engines: {node: '>= 6.0.0'} - deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} mutative@1.1.0: resolution: {integrity: sha512-2PJADREjOusk3iJkD3rXV2YjAxTuaLxdfqtqTEt6vcY07LtEBR1seHuBHXWEIuscqRDGvbauYPs+A4Rj/KTczQ==} @@ -13947,9 +13936,6 @@ packages: resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} engines: {node: '>=18.0.0'} - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -14261,9 +14247,6 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -14551,9 +14534,6 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -14941,9 +14921,6 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -16404,10 +16381,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -26044,11 +26017,11 @@ snapshots: transitivePeerDependencies: - supports-color - concat-stream@1.6.2: + concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 inherits: 2.0.4 - readable-stream: 2.3.8 + readable-stream: 3.6.2 typedarray: 0.0.6 confbox@0.1.8: {} @@ -26090,8 +26063,6 @@ snapshots: core-util-is@1.0.2: optional: true - core-util-is@1.0.3: {} - cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -28419,8 +28390,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isarray@1.0.0: {} - isarray@2.0.5: {} isexe@2.0.0: {} @@ -31000,10 +30969,6 @@ snapshots: mkdirp-classic@0.5.3: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - mkdirp@1.0.4: {} mkdirp@3.0.1: {} @@ -31059,15 +31024,12 @@ snapshots: muggle-string@0.4.1: {} - multer@1.4.5-lts.1: + multer@2.2.0: dependencies: append-field: 1.0.0 busboy: 1.6.0 - concat-stream: 1.6.2 - mkdirp: 0.5.6 - object-assign: 4.1.1 + concat-stream: 2.0.0 type-is: 1.6.18 - xtend: 4.0.2 mutative@1.1.0: {} @@ -31905,8 +31867,6 @@ snapshots: process-ancestry@0.1.0: {} - process-nextick-args@2.0.1: {} - process@0.11.10: {} progress@2.0.3: {} @@ -32290,16 +32250,6 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -32762,8 +32712,6 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.1.2: {} - safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -33348,10 +33296,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -35152,8 +35096,6 @@ snapshots: xmlchars@2.2.0: optional: true - xtend@4.0.2: {} - xxhash-wasm@1.1.0: {} y18n@5.0.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 269c0342fb..6f5c6a69fe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -210,7 +210,7 @@ catalog: minimatch: ^5.1.6 monaco-editor: 0.31.0 mongodb-level: ^0.0.4 - multer: 1.4.5-lts.1 + multer: 2.2.0 next-themes: ^0.3.0 normalize-path: ^3.0.0 ora: ^8.2.0 From 4ef6b6d8f9afa7d596e2f74add3e9c5561e09563 Mon Sep 17 00:00:00 2001 From: "Eli Kent [SSW]" <69125238+kulesy@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:05:11 +1000 Subject: [PATCH 2/3] chore: add missing changeset for the multer 2.2.0 bump (#7436) Closes #7435 **TL;DR** #7079 bumped multer 1.4.5-lts.1 to 2.2.0 without a changeset. This adds one, so both affected packages record the change. **Pain:** multer is consumed via `catalog:` by `next-tinacms-cloudinary` and `next-tinacms-dos`, both published, and neither is in the changesets `ignore` list. #7079 landed the bump with no changeset, and nothing in CI requires one. As it stands, `next-tinacms-cloudinary` will ship the multer 1.x to 2.x major on the back of an unrelated patch entry (`cloudinary-escape-search-expression`), so the changelog describes a search-expression fix while a breaking runtime dependency change rides along. `next-tinacms-dos` gets no release at all, leaving its published dependency on multer 1.4.5-lts.1 while `main` says 2.2.0. **Solution:** Added a changeset marking both packages `patch` and naming the actual change, including that multer 1.x is end of life and 2.x alters file-handling behaviour. Version Packages #7287 is still open, so this is picked up in the current release rather than the one after. Co-authored-by: Claude Opus 5 --- .changeset/bump-multer-off-eol-1x.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/bump-multer-off-eol-1x.md diff --git a/.changeset/bump-multer-off-eol-1x.md b/.changeset/bump-multer-off-eol-1x.md new file mode 100644 index 0000000000..b6960d660e --- /dev/null +++ b/.changeset/bump-multer-off-eol-1x.md @@ -0,0 +1,6 @@ +--- +'next-tinacms-cloudinary': patch +'next-tinacms-dos': patch +--- + +Bump `multer` to 2.2.0. multer 1.x is end of life; 2.x changes file-handling behaviour, so review upload paths when upgrading. From 17931b72762afb575505c383ee6355476207a44e Mon Sep 17 00:00:00 2001 From: "Brook Jeynes [SSW]" Date: Mon, 17 Aug 2026 10:33:22 +1000 Subject: [PATCH 3/3] feat: add announcements banner (#7388) Add an announcement component linked to the /announcement endpoint --------- Issue: TinaCloud#3898 Post alerts to TinaCMS editor and TinaCloud dashboard Assisted-by: OpenCode:big-pickle Signed-off-by: brookjeynes-ssw --- .changeset/clear-waves-take.md | 6 + packages/@tinacms/app/src/preview.tsx | 2 +- .../components/AnnouncementsBanner.test.tsx | 295 ++++++++++++++++++ .../admin/components/AnnouncementsBanner.tsx | 177 +++++++++++ .../tinacms/src/admin/components/Page.tsx | 2 + packages/tinacms/src/admin/index.tsx | 29 +- .../tinacms/src/internalClient/index.test.ts | 106 ++++++- packages/tinacms/src/internalClient/index.ts | 27 ++ .../toolkit/react-sidebar/components/nav.tsx | 4 + 9 files changed, 637 insertions(+), 11 deletions(-) create mode 100644 .changeset/clear-waves-take.md create mode 100644 packages/tinacms/src/admin/components/AnnouncementsBanner.test.tsx create mode 100644 packages/tinacms/src/admin/components/AnnouncementsBanner.tsx diff --git a/.changeset/clear-waves-take.md b/.changeset/clear-waves-take.md new file mode 100644 index 0000000000..2f6a913fb4 --- /dev/null +++ b/.changeset/clear-waves-take.md @@ -0,0 +1,6 @@ +--- +"tinacms": patch +"@tinacms/app": patch +--- + +feat: add announcements banner diff --git a/packages/@tinacms/app/src/preview.tsx b/packages/@tinacms/app/src/preview.tsx index 0c6ba45565..c266f2de37 100644 --- a/packages/@tinacms/app/src/preview.tsx +++ b/packages/@tinacms/app/src/preview.tsx @@ -20,7 +20,7 @@ export const Preview = ( data-test='tina-iframe' id='tina-iframe' ref={props.iframeRef} - className='h-screen w-full bg-white' + className='h-full w-full bg-white' src={props.url} /> ); diff --git a/packages/tinacms/src/admin/components/AnnouncementsBanner.test.tsx b/packages/tinacms/src/admin/components/AnnouncementsBanner.test.tsx new file mode 100644 index 0000000000..91e474fa84 --- /dev/null +++ b/packages/tinacms/src/admin/components/AnnouncementsBanner.test.tsx @@ -0,0 +1,295 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TinaCMSProvider } from '@toolkit/components/tina-cms-provider'; +import { TinaCMS } from '@toolkit/tina-cms'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import pkg from '../../../package.json'; +import type { Announcement } from '../../internalClient'; +import { + AnnouncementsBanner, + AnnouncementsProvider, + DISMISSED_KEY, + resetDismissedStore, +} from './AnnouncementsBanner'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}); + +const announcement = (overrides: Partial = {}): Announcement => ({ + id: 'a-1', + headline: 'New version available', + body: 'Upgrade to the latest.', + severity: 'info', + ...overrides, +}); + +const createCMS = (getAnnouncements: ReturnType) => { + const cms = new TinaCMS({ enabled: true, sidebar: true }); + cms.registerApi('tina', { getAnnouncements }); + return cms; +}; + +const renderBanner = (getAnnouncements: ReturnType) => { + const cms = createCMS(getAnnouncements); + return render( + + + + ); +}; + +describe('AnnouncementsBanner', () => { + beforeEach(() => { + localStorageMock.clear(); + resetDismissedStore(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders nothing before the fetch resolves', () => { + renderBanner(vi.fn().mockReturnValue(new Promise(() => {}))); + expect(screen.queryByText('New version available')).toBeNull(); + }); + + it('fetches announcements using the current package version', async () => { + const getAnnouncements = vi.fn().mockResolvedValue([announcement()]); + renderBanner(getAnnouncements); + await screen.findByText('New version available'); + expect(getAnnouncements).toHaveBeenCalledWith(pkg.version); + }); + + it('renders the announcement headline and body', async () => { + renderBanner(vi.fn().mockResolvedValue([announcement()])); + expect(await screen.findByText('New version available')).not.toBeNull(); + expect(screen.getByText('Upgrade to the latest.')).not.toBeNull(); + }); + + it('renders the current version and the targeted version range', async () => { + renderBanner( + vi.fn().mockResolvedValue([announcement({ versionRange: '<4.0.0' })]) + ); + await screen.findByText('New version available'); + expect( + screen.getByText( + new RegExp(`Your version: ${pkg.version}.*Targets: <4.0.0`) + ) + ).not.toBeNull(); + }); + + it('renders nothing when the fetch resolves to null', async () => { + renderBanner(vi.fn().mockResolvedValue(null)); + await waitFor(() => { + expect(screen.queryByText('New version available')).toBeNull(); + }); + }); + + it('renders nothing when the fetch resolves to an empty list', async () => { + renderBanner(vi.fn().mockResolvedValue([])); + await waitFor(() => { + expect(screen.queryByText('New version available')).toBeNull(); + }); + }); + + it.each([ + ['info', 'border-blue-600/20'], + ['warning', 'border-amber-700/20'], + ['critical', 'border-red-600/20'], + ])( + 'maps the %s severity to the %s callout style', + async (severity, borderClass) => { + const { container } = renderBanner( + vi.fn().mockResolvedValue([announcement({ severity } as Announcement)]) + ); + await screen.findByText('New version available'); + expect( + container.querySelector(`[class*="${borderClass}"]`) + ).not.toBeNull(); + } + ); + + it('falls back to the info style for unknown severities', async () => { + const { container } = renderBanner( + vi + .fn() + .mockResolvedValue([ + announcement({ severity: 'bogus' as Announcement['severity'] }), + ]) + ); + await screen.findByText('New version available'); + expect( + container.querySelector('[class*="border-blue-600/20"]') + ).not.toBeNull(); + }); + + it('dismisses a non-critical announcement and persists the dismissal', async () => { + const getAnnouncements = vi.fn().mockResolvedValue([announcement()]); + const { unmount } = renderBanner(getAnnouncements); + await screen.findByText('New version available'); + + await userEvent.click(screen.getByLabelText('Dismiss')); + + await waitFor(() => { + expect(screen.queryByText('New version available')).toBeNull(); + }); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + DISMISSED_KEY, + JSON.stringify(['a-1']) + ); + + unmount(); + renderBanner(getAnnouncements); + await waitFor(() => expect(getAnnouncements).toHaveBeenCalledTimes(2)); + expect(screen.queryByText('New version available')).toBeNull(); + }); + + it('renders critical announcements without a dismissible button', async () => { + renderBanner( + vi.fn().mockResolvedValue([announcement({ severity: 'critical' })]) + ); + await screen.findByText('New version available'); + const button = screen.getByLabelText( + 'Critical alert cannot be dismissed' + ) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(screen.queryByLabelText('Dismiss')).toBeNull(); + }); + + it('shows critical announcements even when previously dismissed', async () => { + localStorageMock.setItem(DISMISSED_KEY, JSON.stringify(['a-1'])); + renderBanner( + vi.fn().mockResolvedValue([announcement({ severity: 'critical' })]) + ); + expect(await screen.findByText('New version available')).not.toBeNull(); + }); + + it('ignores corrupt dismissed data in localStorage', async () => { + localStorageMock.setItem(DISMISSED_KEY, '{not valid json'); + renderBanner(vi.fn().mockResolvedValue([announcement()])); + expect(await screen.findByText('New version available')).not.toBeNull(); + }); + + it('renders nothing when the fetch fails', async () => { + const error = new Error('network'); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + renderBanner(vi.fn().mockRejectedValue(error)); + await waitFor(() => { + expect(consoleError).toHaveBeenCalledWith( + 'failed to fetch announcements', + error + ); + }); + expect(screen.queryByText('New version available')).toBeNull(); + }); +}); + +describe('AnnouncementsProvider', () => { + beforeEach(() => { + localStorageMock.clear(); + resetDismissedStore(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('fetches once and shares announcements with nested banners', async () => { + const getAnnouncements = vi.fn().mockResolvedValue([announcement()]); + const cms = createCMS(getAnnouncements); + render( + + + + + + + ); + await waitFor(() => { + expect(getAnnouncements).toHaveBeenCalledTimes(1); + }); + expect(await screen.findAllByText('New version available')).toHaveLength(2); + }); + + it('shares dismissal state across nested banners', async () => { + const getAnnouncements = vi.fn().mockResolvedValue([announcement()]); + const cms = createCMS(getAnnouncements); + render( + + + + + + + ); + const buttons = await screen.findAllByLabelText('Dismiss'); + expect(buttons).toHaveLength(2); + + await userEvent.click(buttons[0]); + + await waitFor(() => { + expect(screen.queryByText('New version available')).toBeNull(); + }); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + DISMISSED_KEY, + JSON.stringify(['a-1']) + ); + }); + + it('syncs dismissal across independent banner trees via module store', async () => { + const getAnnouncements = vi.fn().mockResolvedValue([announcement()]); + const cms = createCMS(getAnnouncements); + + // Two separate TinaCMSProvider trees — no shared context. + // The module-level store should still keep them in sync. + const { unmount: unmountA } = render( + + + + ); + await screen.findByText('New version available'); + + const { unmount: unmountB } = render( + + + + ); + await waitFor(() => { + expect(screen.getAllByLabelText('Dismiss')).toHaveLength(2); + }); + const buttons = screen.getAllByLabelText('Dismiss'); + + await userEvent.click(buttons[0]); + + await waitFor(() => { + expect(screen.queryByText('New version available')).toBeNull(); + }); + + unmountA(); + unmountB(); + }); +}); diff --git a/packages/tinacms/src/admin/components/AnnouncementsBanner.tsx b/packages/tinacms/src/admin/components/AnnouncementsBanner.tsx new file mode 100644 index 0000000000..af5d563867 --- /dev/null +++ b/packages/tinacms/src/admin/components/AnnouncementsBanner.tsx @@ -0,0 +1,177 @@ +import { useCMS } from '@tinacms/toolkit'; +import { Callout } from '@toolkit/react-sidebar/components/callout'; +import React from 'react'; +import pkg from '../../../package.json'; +import type { Announcement } from '../../internalClient'; + +export const DISMISSED_KEY = 'tinacms-announcements-dismissed'; + +type Listener = () => void; +let listeners: Set = new Set(); + +function readDismissedFromStorage(): string[] { + try { + return JSON.parse(localStorage.getItem(DISMISSED_KEY) || '[]'); + } catch { + return []; + } +} + +function notify() { + for (const fn of listeners) fn(); +} + +function dismissAnnouncement(id: string) { + const current = readDismissedFromStorage(); + if (current.includes(id)) return; + current.push(id); + localStorage.setItem(DISMISSED_KEY, JSON.stringify(current)); + notify(); +} + +function subscribe(fn: Listener): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +} + +function getDismissed(): string[] { + return readDismissedFromStorage(); +} + +export function resetDismissedStore() { + listeners = new Set(); +} + +export function useAnnouncements() { + const ctx = React.useContext(AnnouncementsContext); + const cms = useCMS(); + const [, forceRender] = React.useState(0); + + React.useEffect(() => { + return subscribe(() => forceRender((n) => n + 1)); + }, []); + + const [localAnnouncements, setLocalAnnouncements] = React.useState< + Announcement[] | null + >(null); + + React.useEffect(() => { + if (ctx) return; + const fetchAnnouncements = async () => { + try { + const result = await cms.api.tina.getAnnouncements(pkg.version); + setLocalAnnouncements(result); + } catch (err) { + console.error('failed to fetch announcements', err); + } + }; + fetchAnnouncements(); + }, [ctx, cms.api.tina]); + + const announcements = ctx?.announcements ?? localAnnouncements; + const dismissed = getDismissed(); + + const dismiss = React.useCallback((id: string) => { + dismissAnnouncement(id); + }, []); + + return { announcements, dismissed, dismiss }; +} + +type AnnouncementsContextValue = { + announcements: Announcement[] | null; +}; + +const AnnouncementsContext = React.createContext< + AnnouncementsContextValue | undefined +>(undefined); + +export const AnnouncementsProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const cms = useCMS(); + const [announcements, setAnnouncements] = React.useState< + Announcement[] | null + >(null); + + React.useEffect(() => { + const fetchAnnouncements = async () => { + try { + const result = await cms.api.tina.getAnnouncements(pkg.version); + setAnnouncements(result); + } catch (err) { + console.error('failed to fetch announcements', err); + } + }; + fetchAnnouncements(); + }, [cms.api.tina]); + + return ( + + {children} + + ); +}; + +const severityToCalloutStyle: Record = { + info: 'info', + warning: 'warning', + critical: 'error', +}; + +export const AnnouncementsBanner = () => { + const { announcements, dismissed, dismiss } = useAnnouncements(); + + if (!announcements || announcements.length === 0) return null; + + const visible = announcements.filter( + (a) => a.severity === 'critical' || !dismissed.includes(a.id) + ); + + if (visible.length === 0) return null; + + return ( +
+ {visible.map((a) => ( + +
+
+ {a.headline} + {a.severity !== 'critical' ? ( + + ) : ( + + )} +
+ {a.body &&
{a.body}
} +
+ Your version: {pkg.version} + {a.versionRange ? ` \u00B7 Targets: ${a.versionRange}` : ''} +
+
+
+ ))} +
+ ); +}; diff --git a/packages/tinacms/src/admin/components/Page.tsx b/packages/tinacms/src/admin/components/Page.tsx index 7877e01128..0e799a509b 100644 --- a/packages/tinacms/src/admin/components/Page.tsx +++ b/packages/tinacms/src/admin/components/Page.tsx @@ -7,6 +7,7 @@ import { } from '@tinacms/toolkit'; import React from 'react'; import { TinaIcon } from '@toolkit/icons'; +import { AnnouncementsBanner } from './AnnouncementsBanner'; export const PageWrapper = ({ headerClassName, @@ -27,6 +28,7 @@ export const PageWrapper = ({ + {children} ); diff --git a/packages/tinacms/src/admin/index.tsx b/packages/tinacms/src/admin/index.tsx index 1f94c3ea7b..244407ca7d 100644 --- a/packages/tinacms/src/admin/index.tsx +++ b/packages/tinacms/src/admin/index.tsx @@ -1,3 +1,4 @@ +import { ERR_NOT_INDEXED } from '@tinacms/schema-tools'; import { Button, Modal, @@ -9,7 +10,6 @@ import { TinaCMS, useCMS, } from '@tinacms/toolkit'; -import { ERR_NOT_INDEXED } from '@tinacms/schema-tools'; import React, { useState, useEffect } from 'react'; import { Route, @@ -19,9 +19,9 @@ import { useParams, } from 'react-router-dom'; +import Sidebar from './components/AdminNav'; import GetCMS from './components/GetCMS'; import Layout from './components/Layout'; -import Sidebar from './components/AdminNav'; import CollectionCreatePage from './pages/CollectionCreatePage'; import CollectionDuplicatePage from './pages/CollectionDuplicatePage'; @@ -30,14 +30,18 @@ import CollectionUpdatePage from './pages/CollectionUpdatePage'; import DashboardPage from './pages/DashboardPage'; import ScreenPage from './pages/ScreenPage'; +import pkg from '../../package.json'; import { Client, TinaCloudAuthProvider } from '../internalClient'; -import { TinaAdminApi } from './api'; import { - initializePostHog, - TinaCMSStartedEvent, TelemetryMode, + TinaCMSStartedEvent, + initializePostHog, } from '../lib/posthog'; -import pkg from '../../package.json'; +import { TinaAdminApi } from './api'; +import { + AnnouncementsBanner, + AnnouncementsProvider, +} from './components/AnnouncementsBanner'; type AuthType = 'tinacloud' | 'self-hosted' | 'local' | 'other'; @@ -195,7 +199,14 @@ const PreviewInner = ({ preview, config }) => { }, 100); }, [ref.current]); const Preview = preview; - return ; + return ( +
+ +
+ +
+
+ ); }; const CheckSchema = ({ @@ -306,7 +317,7 @@ export const TinaAdmin = ({ }); const hasRouter = Boolean(collectionWithRouter); return ( - <> + @@ -416,7 +427,7 @@ export const TinaAdmin = ({ - + ); } else { return ( diff --git a/packages/tinacms/src/internalClient/index.test.ts b/packages/tinacms/src/internalClient/index.test.ts index 1e076fb9ae..ded573f37b 100644 --- a/packages/tinacms/src/internalClient/index.test.ts +++ b/packages/tinacms/src/internalClient/index.test.ts @@ -1,9 +1,9 @@ -import { Client, LocalClient, LocalAuthProvider } from './index'; import { EDITORIAL_WORKFLOW_ERROR, EDITORIAL_WORKFLOW_STATUS, EditorialWorkflowErrorDetails, } from '../toolkit/form-builder/editorial-workflow-constants'; +import { Client, LocalAuthProvider, LocalClient } from './index'; const makeResponse = ({ status, @@ -215,6 +215,110 @@ describe('Tina Client', () => { }); }); + describe('getAnnouncements', () => { + let client: Client; + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns null without fetching for a custom content API client', async () => { + client = buildClient({ + customContentApiUrl: 'https://self-hosted.example.com/graphql', + }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await client.getAnnouncements('1.2.3'); + + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns null without fetching when contentApiUrlOverride is set in the schema', async () => { + client = buildClient({ + schema: { + collections: [], + config: { + contentApiUrlOverride: '/api/tina/gql', + }, + } as any, + }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await client.getAnnouncements('1.2.3'); + + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns null without fetching for a LocalClient', async () => { + client = new LocalClient(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await client.getAnnouncements('1.2.3'); + + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fetches from the content API base and returns the announcements for a TinaCloud client', async () => { + client = buildClient(); + const announcements = [ + { + id: 'a-1', + headline: 'Hello', + body: 'World', + severity: 'info', + }, + ]; + stubFetchOnce( + makeResponse({ + status: 200, + body: { announcements }, + }) + ); + + const result = await client.getAnnouncements('1.2.3'); + + expect(result).toEqual(announcements); + expect(global.fetch).toHaveBeenCalledWith( + 'https://content.tinajs.io/announcement?version=1.2.3' + ); + }); + + it('returns null when the response is not ok', async () => { + client = buildClient(); + stubFetchOnce( + makeResponse({ + status: 500, + body: { error: 'boom' }, + }) + ); + + const result = await client.getAnnouncements('1.2.3'); + + expect(result).toBeNull(); + }); + + it('returns null when the fetch rejects', async () => { + client = buildClient(); + const fetchMock = vi.fn().mockRejectedValueOnce(new Error('network')); + vi.stubGlobal('fetch', fetchMock); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await client.getAnnouncements('1.2.3'); + + expect(result).toBeNull(); + consoleError.mockRestore(); + }); + }); + describe('branch management', () => { let client: Client; let fetchWithToken: ReturnType; diff --git a/packages/tinacms/src/internalClient/index.ts b/packages/tinacms/src/internalClient/index.ts index 74d2447e64..a13da1c4f8 100644 --- a/packages/tinacms/src/internalClient/index.ts +++ b/packages/tinacms/src/internalClient/index.ts @@ -47,6 +47,14 @@ interface TinaSearchConfig { fuzzyOptions?: FuzzySearchOptions; } +export interface Announcement { + id: string; + headline: string; + body: string; + severity: 'info' | 'warning' | 'critical'; + versionRange?: string; +} + type EditorialWorkflowStatusUpdate = { status: string; message?: string; @@ -485,6 +493,25 @@ mutation addPendingDocumentMutation( } } + async getAnnouncements(version: string): Promise { + if ( + this.isCustomContentApi || + this.schema?.config?.config?.contentApiUrlOverride + ) { + return null; + } + const url = `${this.contentApiBase}/announcement?version=${version}`; + try { + const res = await fetch(url); + if (!res.ok) return null; + const val = await res.json(); + return val.announcements as Announcement[]; + } catch (e) { + console.error(e); + return null; + } + } + waitForIndexStatus({ ref }: { ref: string }) { let unknownCount = 0; try { diff --git a/packages/tinacms/src/toolkit/react-sidebar/components/nav.tsx b/packages/tinacms/src/toolkit/react-sidebar/components/nav.tsx index a39332db19..842580f3a2 100644 --- a/packages/tinacms/src/toolkit/react-sidebar/components/nav.tsx +++ b/packages/tinacms/src/toolkit/react-sidebar/components/nav.tsx @@ -10,6 +10,7 @@ import * as React from 'react'; import { VersionInfo } from './VersionInfo'; import { NavContext } from './nav-context'; import { SyncStatusButton, SyncStatusModal } from './sync-status'; +import { AnnouncementsBanner } from '../../../admin/components/AnnouncementsBanner'; interface NavCollection { label?: string; @@ -153,6 +154,9 @@ export const Nav = ({ {children}
+
+ +
{showCollections && ( <>