Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/changelog-help-menu.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions docker/hyperdx/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 3 additions & 0 deletions packages/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/app/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
'^@/(.*)$': '<rootDir>/src/$1',
'^ky-universal$': '<rootDir>/src/__mocks__/ky-universal.ts',
'^ky$': '<rootDir>/src/__mocks__/ky-universal.ts',
'^react-markdown$': '<rootDir>/src/__mocks__/react-markdown.tsx',
},
setupFilesAfterEnv: ['<rootDir>/src/setupTests.tsx'],
// Coverage floors are scoped to hooks/ and utils/ only — the genuinely
Expand Down
28 changes: 27 additions & 1 deletion packages/app/next.config.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/app/scripts/prepare-clickhouse-build-export.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/__mocks__/react-markdown.tsx
Original file line number Diff line number Diff line change
@@ -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}</>;
}
14 changes: 14 additions & 0 deletions packages/app/src/components/AppNav/AppNav.components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<>
Expand Down Expand Up @@ -234,6 +240,13 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => {
>
Setup Instructions
</Menu.Item>
<Menu.Item
data-testid="changelog-menu-item"
leftSection={<IconSparkles size={16} />}
onClick={openChangelogModal}
>
What&apos;s new
</Menu.Item>
<Menu.Item
data-testid="keyboard-shortcuts-menu-item"
leftSection={<IconKeyboard size={16} />}
Expand All @@ -257,6 +270,7 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => {
opened={shortcutsOpened}
onClose={closeShortcutsModal}
/>
<ChangelogModal opened={changelogOpened} onClose={closeChangelogModal} />
</>
);
};
Expand Down
61 changes: 61 additions & 0 deletions packages/app/src/components/AppNav/ChangelogModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
title="What's New"
size="lg"
centered
scrollAreaComponent={ScrollArea.Autosize}
>
<div className="hdx-markdown" data-testid="changelog-modal">
{isError ? (
<Text size="sm" c="dimmed">
Unable to load the changelog.
</Text>
) : markdown == null ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<ReactMarkdown>{markdown}</ReactMarkdown>
)}
</div>
</Modal>
);
};
58 changes: 58 additions & 0 deletions packages/app/tests/e2e/core/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,52 @@ 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"]',
);
const discordItem = page.locator('[data-testid="discord-menu-item"]');

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
// <h2>), 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();
Expand All @@ -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 },
);
});
});
Loading