diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts new file mode 100644 index 000000000..af123abf2 --- /dev/null +++ b/apps/frontend/.storybook/main.ts @@ -0,0 +1,17 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +import path from 'path'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'], + addons: [], + framework: { + name: '@storybook/react-vite', + options: { + builder: { + viteConfigPath: path.resolve(__dirname, 'vite.config.ts'), + }, + }, + }, +}; + +export default config; diff --git a/apps/frontend/.storybook/preview.tsx b/apps/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..969f3026d --- /dev/null +++ b/apps/frontend/.storybook/preview.tsx @@ -0,0 +1,35 @@ +import type { Preview } from '@storybook/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { UIProvider } from '@packmind/ui'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, +}); + +const preview: Preview = { + decorators: [ + (Story) => ( + + + + + + ), + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/apps/frontend/.storybook/vite.config.ts b/apps/frontend/.storybook/vite.config.ts new file mode 100644 index 000000000..9b6086c95 --- /dev/null +++ b/apps/frontend/.storybook/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import path from 'path'; + +export default defineConfig(() => { + // Determine edition mode (defaults to OSS if not explicitly set to 'proprietary') + const isOssMode = process.env.PACKMIND_EDITION !== 'proprietary'; + + // Configure resolve aliases based on edition + const resolveAliases = isOssMode + ? { + '@packmind/proprietary/frontend': path.resolve( + __dirname, + '../src/domain/editions/stubs', + ), + } + : { + '@packmind/proprietary/frontend': path.resolve(__dirname, '../src'), + }; + + return { + root: path.resolve(__dirname, '..'), + cacheDir: '../../../node_modules/.vite/apps/frontend-storybook', + assetsInclude: ['**/*.svg', '**/*.png'], + define: { + __PACKMIND_EDITION__: JSON.stringify( + process.env.PACKMIND_EDITION || 'oss', + ), + }, + resolve: { + alias: resolveAliases, + }, + plugins: [nxViteTsPaths()], + }; +}); diff --git a/apps/frontend/project.json b/apps/frontend/project.json index 5f462e0ee..791cf0943 100644 --- a/apps/frontend/project.json +++ b/apps/frontend/project.json @@ -26,6 +26,23 @@ "options": { "command": "nx dev frontend" } + }, + "storybook": { + "executor": "nx:run-commands", + "continuous": true, + "options": { + "cwd": "apps/frontend", + "command": "storybook dev -p 6006" + } + }, + "build-storybook": { + "executor": "nx:run-commands", + "cache": true, + "outputs": ["{projectRoot}/storybook-static"], + "options": { + "cwd": "apps/frontend", + "command": "storybook build" + } } } } diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx index 1084a8a5b..8a3d6ffc2 100644 --- a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx +++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx @@ -31,7 +31,6 @@ import { import { HighlightedText, HighlightedRuleBox } from '../HighlightedContent'; import { UnifiedMarkdownViewer } from '../UnifiedMarkdownViewer'; import { useDiffNavigation } from '../../hooks/useDiffNavigation'; -import { renderMarkdownDiffOrPreview } from '../SkillReviewDetail/SkillContent/renderMarkdownDiffOrPreview'; interface ProposalReviewPanelProps { selectedStandard: Standard | undefined; @@ -244,15 +243,24 @@ export function ProposalReviewPanel({ ? renderDiffText(payload.oldValue, payload.newValue) : selectedStandard.name} - {renderMarkdownDiffOrPreview( - isDescriptionDiff, - showPreview, - payload, - selectedStandard.description, - { - previewPaddingVariant: 'none', - defaultPaddingVariant: 'none', - }, + {isDescriptionDiff ? ( + + ) : ( + + + )} {/* Rules Section */} diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx new file mode 100644 index 000000000..2622f78e0 --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { + UnifiedMarkdownViewer, + UnifiedMarkdownViewerProps, +} from './UnifiedMarkdownViewer'; + +const MultiViewer: React.FunctionComponent< + Omit +> = (props) => { + return ( +
+
+

Unified view

+ +
+ +
+

Diff view

+ +
+
+ ); +}; + +const meta: Meta = { + title: 'ChangeProposals/UnifiedMarkdownViewer', + component: MultiViewer, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const MarkdownFormatting: Story = { + args: { + oldValue: `# My title + +This was my content + +There was a line here. + +## My sub-heading + +My list: + - one item + - another item + +# Code example + +\`\`\`javascript +function hello() { + console.log('Hello'); +} +\`\`\` +`, + newValue: `# My title + +This is my updated content + +## My sub-heading + +My list: + - first item + - a new item + - another item + + # Example + +\`\`\`javascript +function hello() { + console.log('Hello, ' + name); +} +\`\`\``, + proposalNumbers: [1], + }, +}; + +export const TagChanges: Story = { + args: { + oldValue: '# My title\n\nThis was my content', + newValue: '## My title\n\nThis is my **content**', + proposalNumbers: [1], + }, +}; + +export const MultipleProposals: Story = { + args: { + oldValue: 'This is my content', + newValue: 'This is my updated content', + proposalNumbers: [1, 2, 3], + }, +}; + +export const CodeBlock: Story = { + args: { + oldValue: `# Example + +\`\`\`javascript +function hello() { + console.log('Hello'); +} +\`\`\``, + newValue: `# Example + +\`\`\`javascript +function hello(name) { + console.log('Hello, ' + name); +} +\`\`\``, + proposalNumbers: [1], + }, +}; + +export const ListChanges: Story = { + args: { + oldValue: `# Todo List + +- Item 1 +- Item 2 +- Item 3`, + newValue: `# Todo List + +- Item 1 +- Updated Item 2 +- Item 3 +- Item 4`, + proposalNumbers: [1], + }, +}; + +export const ComplexChanges: Story = { + args: { + oldValue: `# My title + +This was my content + +There was a line here. + +## My sub-heading + +My list: + - one item + - another item`, + newValue: `# My title + +This is my updated content + +## My sub-heading + +My list: + - first item + - a new item + - another item`, + proposalNumbers: [1, 2], + }, +}; diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx index 915eb0146..380635493 100644 --- a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx +++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { PMBox } from '@packmind/ui'; import { MilkdownProvider } from '@milkdown/react'; -import { DiffMarkdownEditor } from '../../../shared/components/editor/DiffMarkdownEditor'; +import { + DiffMarkdownEditor, + IDiffMarkdownEditorProps, +} from '../../../shared/components/editor/DiffMarkdownEditor'; -interface UnifiedMarkdownViewerProps { +export interface UnifiedMarkdownViewerProps { oldValue: string; newValue: string; proposalNumbers: number[]; + displayMode?: IDiffMarkdownEditorProps['displayMode']; } /** @@ -18,6 +22,7 @@ export function UnifiedMarkdownViewer({ oldValue, newValue, proposalNumbers, + displayMode, }: UnifiedMarkdownViewerProps) { return ( @@ -26,6 +31,7 @@ export function UnifiedMarkdownViewer({ oldValue={oldValue} newValue={newValue} proposalNumbers={proposalNumbers} + displayMode={displayMode ?? 'unified'} paddingVariant="none" /> diff --git a/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts b/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts new file mode 100644 index 000000000..85688573d --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts @@ -0,0 +1,13 @@ +import { buildDiffHtml } from './markdownDiff'; + +/** + * Builds inline diff markdown content for the "diff" display mode. + * Uses buildDiffHtml to generate HTML with and tags + * showing additions and deletions inline. + */ +export function buildInlineDiffMarkdown( + oldValue: string, + newValue: string, +): string { + return buildDiffHtml(oldValue, newValue); +} diff --git a/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts b/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts new file mode 100644 index 000000000..3502b7a7a --- /dev/null +++ b/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts @@ -0,0 +1,423 @@ +import { parseAndDiffMarkdown } from './markdownBlockDiff'; + +describe('markdownBlockDiff', () => { + describe('parseAndDiffMarkdown', () => { + it('parses and diffs a simple text change', () => { + const oldValue = 'This was my content'; + const newValue = 'This is my updated content'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'paragraph', + content: 'This is my updated content', + status: 'updated', + diffContent: expect.stringMatching( + /This --was--\+\+is\+\+ my \+\+updated \+\+content/, + ), + }), + ]); + }); + + it('handles heading blocks with level tracking', () => { + const oldValue = '# My title'; + const newValue = '# My updated title'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'heading', + level: '#', + content: 'My updated title', + status: 'updated', + diffContent: expect.stringContaining('++updated ++'), + }), + ]); + }); + + it('preserves unchanged blocks', () => { + const markdown = '# My title\n\nSome content that stays the same'; + + const result = parseAndDiffMarkdown(markdown, markdown); + + expect(result).toEqual([ + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ status: 'unchanged' }), + ]); + }); + + it('marks added blocks correctly', () => { + const oldValue = '# Title'; + const newValue = '# Title\n\nNew paragraph'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'heading', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'paragraph', + content: 'New paragraph', + status: 'added', + diffContent: '++New paragraph++', + }), + ]), + ); + }); + + it('marks deleted blocks correctly', () => { + const oldValue = '# Title\n\nParagraph to delete'; + const newValue = '# Title'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'heading', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'paragraph', + content: 'Paragraph to delete', + status: 'deleted', + diffContent: '--Paragraph to delete--', + }), + ]), + ); + }); + + it('handles the complex user story example correctly', () => { + const oldValue = `# My title + +This was my content + +There was a line here. + +## My sub-heading + +My list: + - one item + - another item`; + + const newValue = `# My title + +This is my updated content + +## My sub-heading + +My list: + - first item + - a new item + - another item`; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'heading', + level: '#', + content: 'My title', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'heading', + level: '##', + content: 'My sub-heading', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'paragraph', + content: 'This is my updated content', + status: 'updated', + diffContent: expect.stringMatching(/--was--.*\+\+is\+\+/), + }), + expect.objectContaining({ + type: 'paragraph', + content: 'There was a line here.', + status: 'deleted', + diffContent: '--There was a line here.--', + }), + expect.objectContaining({ + type: 'list', + status: 'updated', + items: expect.arrayContaining([ + expect.objectContaining({ + content: 'one item', + status: 'deleted', + }), + expect.objectContaining({ + content: 'first item', + status: 'added', + }), + expect.objectContaining({ + content: 'a new item', + status: 'added', + }), + expect.objectContaining({ + content: 'another item', + status: 'unchanged', + }), + ]), + }), + ]), + ); + }); + + it('handles multiple heading levels', () => { + const oldValue = '# H1\n## H2\n### H3'; + const newValue = '# H1\n## Updated H2\n### H3'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'heading', + level: '#', + status: 'unchanged', + }), + expect.objectContaining({ + type: 'heading', + level: '##', + content: 'Updated H2', + status: 'updated', + }), + expect.objectContaining({ + type: 'heading', + level: '###', + status: 'unchanged', + }), + ]); + }); + + it('computes word-level diffs for code blocks', () => { + const oldValue = '```js\nconst x = 1;\n```'; + const newValue = '```js\nconst x = 2;\n```'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'code', + status: 'updated', + language: 'js', + diffContent: expect.stringMatching(/--1--.*\+\+2\+\+/), + lineDiff: expect.stringContaining('-const x = 1;'), + }), + ]); + }); + + it('includes additions in line diff for code blocks', () => { + const oldValue = '```js\nconst x = 1;\n```'; + const newValue = '```js\nconst x = 2;\n```'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result[0].lineDiff).toContain('+const x = 2;'); + }); + + it('computes line-level diffs for multi-line code blocks', () => { + const oldValue = `\`\`\`javascript +function hello() { + console.log('Hello'); +} +\`\`\``; + + const newValue = `\`\`\`javascript +function hello(name) { + console.log('Hello, ' + name); +} +\`\`\``; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'code', + status: 'updated', + language: 'javascript', + lineDiff: expect.stringContaining('-function hello() {'), + }), + ]); + }); + + it('tracks list item additions', () => { + const oldValue = '- item 1\n- item 2'; + const newValue = '- item 1\n- item 2\n- item 3'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'list', + status: 'updated', + items: [ + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ + status: 'added', + diffContent: '++item 3++', + }), + ], + }), + ]); + }); + + it('tracks list item deletions', () => { + const oldValue = '- item 1\n- item 2\n- item 3'; + const newValue = '- item 1\n- item 3'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'list', + status: 'updated', + items: [ + expect.objectContaining({ status: 'unchanged' }), + expect.objectContaining({ + status: 'deleted', + diffContent: '--item 2--', + }), + expect.objectContaining({ status: 'unchanged' }), + ], + }), + ]); + }); + + it('returns empty array for empty markdown', () => { + const result = parseAndDiffMarkdown('', ''); + + expect(result).toEqual([]); + }); + + it('handles transition from empty to content', () => { + const result = parseAndDiffMarkdown('', '# New heading'); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'heading', + status: 'added', + }), + ]); + }); + + it('escapes HTML to prevent XSS', () => { + const oldValue = 'Content with '; + const newValue = 'Content with
safe
'; + + const result = parseAndDiffMarkdown(oldValue, newValue); + + expect(result[0].diffContent).toEqual( + expect.not.stringContaining('