diff --git a/.changeset/changelog-help-menu.md b/.changeset/changelog-help-menu.md new file mode 100644 index 0000000000..fe7639a7e0 --- /dev/null +++ b/.changeset/changelog-help-menu.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": minor +--- + +feat: add a "What's new" item to the Help menu that opens the app's changelog rendered as Markdown diff --git a/docker/hyperdx/Dockerfile b/docker/hyperdx/Dockerfile index bb1e2bb1df..a3d612ee64 100644 --- a/docker/hyperdx/Dockerfile +++ b/docker/hyperdx/Dockerfile @@ -86,6 +86,9 @@ COPY --from=app ./pages ./packages/app/pages COPY --from=app ./public ./packages/app/public COPY --from=app ./styles ./packages/app/styles COPY --from=app ./types ./packages/app/types +# next.config.mjs copies this into public/ so the in-app changelog viewer can +# fetch it; the build fails loudly if it is missing (see next.config.mjs). +COPY --from=app ./CHANGELOG.md ./packages/app/CHANGELOG.md ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_OUTPUT_STANDALONE=true diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 350338fc9a..42c5f52d09 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -7,5 +7,8 @@ next.config.original.js public/__ENV.js +# Copied from CHANGELOG.md at build time (see next.config.mjs) +public/CHANGELOG.md + # E2E test authentication state tests/e2e/.auth/ diff --git a/packages/app/Dockerfile b/packages/app/Dockerfile index eafc9c923f..21f747bba4 100644 --- a/packages/app/Dockerfile +++ b/packages/app/Dockerfile @@ -60,6 +60,9 @@ COPY ./packages/app/pages ./packages/app/pages COPY ./packages/app/public ./packages/app/public COPY ./packages/app/styles ./packages/app/styles COPY ./packages/app/types ./packages/app/types +# next.config.mjs copies this into public/ so the in-app changelog viewer can +# fetch it; the build fails loudly if it is missing (see next.config.mjs). +COPY ./packages/app/CHANGELOG.md ./packages/app/CHANGELOG.md RUN yarn workspace @hyperdx/app run build RUN --mount=type=cache,target=/root/.yarn/berry/cache,id=yarn-cache \ rm -rf node_modules && yarn workspaces focus @hyperdx/app --production diff --git a/packages/app/jest.config.js b/packages/app/jest.config.js index 9875aabdc1..c858eb81ab 100644 --- a/packages/app/jest.config.js +++ b/packages/app/jest.config.js @@ -23,6 +23,7 @@ module.exports = { '^@/(.*)$': '/src/$1', '^ky-universal$': '/src/__mocks__/ky-universal.ts', '^ky$': '/src/__mocks__/ky-universal.ts', + '^react-markdown$': '/src/__mocks__/react-markdown.tsx', }, setupFilesAfterEnv: ['/src/setupTests.tsx'], // Coverage floors are scoped to hooks/ and utils/ only — the genuinely diff --git a/packages/app/next.config.mjs b/packages/app/next.config.mjs index c3907eef6c..93abc3326a 100644 --- a/packages/app/next.config.mjs +++ b/packages/app/next.config.mjs @@ -1,5 +1,5 @@ import { configureRuntimeEnv } from 'next-runtime-env/build/configure.js'; -import { readFileSync } from 'fs'; +import { copyFileSync, readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -12,6 +12,32 @@ const packageJson = JSON.parse( ); const { version } = packageJson; +// Copy CHANGELOG.md into public/ so the in-app "What's new" viewer can fetch it +// as a static asset. Done here (rather than a package.json pre-script) because +// Yarn 4 does not run arbitrary pre/post lifecycle scripts; next.config is +// evaluated by both `next dev` (Turbopack) and `next build` (Webpack), so this +// runs in every build mode. The ClickStack static export additionally needs +// `.md` allow-listed in scripts/prepare-clickhouse-build-export.js, and the +// Docker builder stages must COPY the file in (see the Dockerfiles). +try { + copyFileSync( + join(__dirname, 'CHANGELOG.md'), + join(__dirname, 'public', 'CHANGELOG.md'), + ); +} catch (err) { + // Fail loudly during a production build: a missing CHANGELOG.md there means + // the shipped image would silently render "Unable to load" for every user. + // Stay non-fatal otherwise — `next start` re-evaluates this config at runtime + // where the source file is absent but public/CHANGELOG.md already exists from + // the build stage, and dev tolerates its absence. + if (process.env.NEXT_PHASE === 'phase-production-build') { + throw new Error( + `Failed to copy CHANGELOG.md into public/ during build: ${err.message}`, + ); + } + console.warn('Could not copy CHANGELOG.md into public/:', err.message); +} + // Support legacy consumers of next-runtime-env that expect this value under window.__ENV process.env.NEXT_PUBLIC_APP_VERSION = version; diff --git a/packages/app/scripts/prepare-clickhouse-build-export.js b/packages/app/scripts/prepare-clickhouse-build-export.js index 6290c13a48..4dc95b2865 100644 --- a/packages/app/scripts/prepare-clickhouse-build-export.js +++ b/packages/app/scripts/prepare-clickhouse-build-export.js @@ -12,6 +12,7 @@ const ALLOWED_EXTENSIONS = [ '.png', '.svg', '.ico', + '.md', // CHANGELOG.md, fetched by the in-app "What's new" viewer ]; // removes pyodide from a next static build. We want a small bundle size, so that feature would just be ignored diff --git a/packages/app/src/__mocks__/react-markdown.tsx b/packages/app/src/__mocks__/react-markdown.tsx new file mode 100644 index 0000000000..75fe5693f9 --- /dev/null +++ b/packages/app/src/__mocks__/react-markdown.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +// react-markdown v10 is ESM-only, which Jest (with transformIgnorePatterns: +// ['/node_modules/']) cannot parse. Unit tests don't exercise Markdown-to-HTML +// rendering (that's covered by E2E), so this stub just renders the raw markdown +// string, keeping any component that imports react-markdown loadable in tests. +export default function ReactMarkdown({ + children, +}: { + children?: React.ReactNode; +}) { + return <>{children}; +} diff --git a/packages/app/src/components/AppNav/AppNav.components.tsx b/packages/app/src/components/AppNav/AppNav.components.tsx index 93485c39ab..95344a749c 100644 --- a/packages/app/src/components/AppNav/AppNav.components.tsx +++ b/packages/app/src/components/AppNav/AppNav.components.tsx @@ -24,11 +24,13 @@ import { IconKeyboard, IconLogout, IconSettings, + IconSparkles, IconUserCog, } from '@tabler/icons-react'; import { IS_LOCAL_MODE } from '@/config'; +import { ChangelogModal } from './ChangelogModal'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import styles from './AppNav.module.scss'; @@ -184,6 +186,10 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => { shortcutsOpened, { open: openShortcutsModal, close: closeShortcutsModal }, ] = useDisclosure(false); + const [ + changelogOpened, + { open: openChangelogModal, close: closeChangelogModal }, + ] = useDisclosure(false); return ( <> @@ -234,6 +240,13 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => { > Setup Instructions + } + onClick={openChangelogModal} + > + What's new + } @@ -257,6 +270,7 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => { opened={shortcutsOpened} onClose={closeShortcutsModal} /> + ); }; diff --git a/packages/app/src/components/AppNav/ChangelogModal.tsx b/packages/app/src/components/AppNav/ChangelogModal.tsx new file mode 100644 index 0000000000..b4e15d2159 --- /dev/null +++ b/packages/app/src/components/AppNav/ChangelogModal.tsx @@ -0,0 +1,61 @@ +import { useRouter } from 'next/router'; +import ReactMarkdown from 'react-markdown'; +import { Center, Loader, Modal, ScrollArea, Text } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; + +// The changelog is copied into public/ at build time (see next.config.mjs) so +// it ships as a static asset in every build mode, including the ClickStack +// static export. Fetched lazily the first time the modal opens. +const CHANGELOG_FILE = 'CHANGELOG.md'; + +export const ChangelogModal = ({ + opened, + onClose, +}: { + opened: boolean; + onClose: () => void; +}) => { + // basePath is '' normally and '/clickstack' in the ClickStack build, where + // the static asset is served under that prefix. + const { basePath } = useRouter(); + + const { data: markdown, isError } = useQuery({ + enabled: opened, + queryKey: ['changelog', basePath], + staleTime: Infinity, + queryFn: async () => { + const res = await fetch(`${basePath}/${CHANGELOG_FILE}`); + if (!res.ok) { + throw new Error(`Failed to load changelog: ${res.status}`); + } + const text = await res.text(); + // Drop the leading `# @hyperdx/app` package heading. + return text.replace(/^#\s*@hyperdx\/app\s*\n/, ''); + }, + }); + + return ( + +
+ {isError ? ( + + Unable to load the changelog. + + ) : markdown == null ? ( +
+ +
+ ) : ( + {markdown} + )} +
+
+ ); +}; diff --git a/packages/app/tests/e2e/core/navigation.spec.ts b/packages/app/tests/e2e/core/navigation.spec.ts index 2fd576e8ee..777a62bff3 100644 --- a/packages/app/tests/e2e/core/navigation.spec.ts +++ b/packages/app/tests/e2e/core/navigation.spec.ts @@ -137,6 +137,7 @@ test.describe('Navigation', { tag: ['@core'] }, () => { const setupItem = page.locator( '[data-testid="setup-instructions-menu-item"]', ); + const changelogItem = page.locator('[data-testid="changelog-menu-item"]'); const shortcutsItem = page.locator( '[data-testid="keyboard-shortcuts-menu-item"]', ); @@ -144,11 +145,44 @@ test.describe('Navigation', { tag: ['@core'] }, () => { await expect(documentationItem).toBeVisible(); await expect(setupItem).toBeVisible(); + await expect(changelogItem).toBeVisible(); await expect(shortcutsItem).toBeVisible(); await expect(discordItem).toBeVisible(); }); + await test.step('Open changelog from help menu with rendered markdown', async () => { + const changelogItem = page.getByTestId('changelog-menu-item'); + await changelogItem.scrollIntoViewIfNeeded(); + await changelogItem.click(); + + const dialog = page.getByRole('dialog', { name: "What's New" }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + const modal = dialog.getByTestId('changelog-modal'); + const heading = modal.locator('h2').first(); + const errorText = modal.getByText('Unable to load the changelog.'); + + // Wait for the async fetch to settle into either outcome, then assert it + // settled on success. The changelog asset is copied into public/ by + // next.config, so a broken copy fails here fast and legibly instead of + // timing out on the heading check. + await expect(heading.or(errorText)).toBeVisible({ timeout: 10_000 }); + await expect(errorText).toHaveCount(0); + + // The changelog markdown renders as real HTML (version headings become + //

), so a visible heading proves it was parsed, not shown raw. + await expect(heading).toBeVisible(); + + // Close so the help menu can be reopened for the next step. + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + }); + await test.step('Open keyboard shortcuts from help menu', async () => { + // The changelog step closed the menu, so reopen it first. + const helpMenuTrigger = page.getByTestId('help-menu-trigger'); + await helpMenuTrigger.click({ timeout: 10000 }); + const shortcutsItem = page.getByTestId('keyboard-shortcuts-menu-item'); await shortcutsItem.scrollIntoViewIfNeeded(); await shortcutsItem.click(); @@ -158,4 +192,28 @@ test.describe('Navigation', { tag: ['@core'] }, () => { ).toBeVisible({ timeout: 10_000 }); }); }); + + test('should show a fallback when the changelog fails to load', async ({ + page, + }) => { + // Force the changelog asset to 404 so the modal's error branch renders. + await page.route('**/CHANGELOG.md', route => + route.fulfill({ status: 404, body: 'not found' }), + ); + + await expect(page.locator('[data-testid="nav-link-search"]')).toBeVisible(); + + const helpMenuTrigger = page.getByTestId('help-menu-trigger'); + await helpMenuTrigger.click({ timeout: 10000 }); + + const changelogItem = page.getByTestId('changelog-menu-item'); + await changelogItem.scrollIntoViewIfNeeded(); + await changelogItem.click(); + + const dialog = page.getByRole('dialog', { name: "What's New" }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Unable to load the changelog.')).toBeVisible( + { timeout: 10_000 }, + ); + }); });