diff --git a/packages/design-system/src/components/ds-comment-bubble/__tests__/__snapshots__/ds-comment-bubble.docs.snap b/packages/design-system/src/components/ds-comment-bubble/__tests__/__snapshots__/ds-comment-bubble.docs.snap new file mode 100644 index 000000000..83ac202dd --- /dev/null +++ b/packages/design-system/src/components/ds-comment-bubble/__tests__/__snapshots__/ds-comment-bubble.docs.snap @@ -0,0 +1,426 @@ +# DsCommentBubble docs snippets + +## Initial + +### Show code + {}} + onClose={() => {}} + onSend={() => {}} + onValueChange={() => {}} + referenceTag="Resource allocation" + value="" +/> + +### MCP manifest +const Initial = () => ; + +## Typing + +### Show code + {}} + onClose={() => {}} + onSend={() => {}} + onValueChange={() => {}} + referenceTag="Resource allocation" + value="This is a new comment..." +/> + +### MCP manifest +const Typing = () => ; + +## Typing With Action Required + +### Show code + {}} + onClose={() => {}} + onSend={() => {}} + onValueChange={() => {}} + referenceTag="Resource allocation" + value="This needs attention!" +/> + +### MCP manifest +const TypingWithActionRequired = () => ; + +## Hidden Action Required + +### Show code + {}} + onClose={() => {}} + onSend={() => {}} + onValueChange={() => {}} + referenceTag="Resource allocation" + value="This is a new comment..." +/> + +### MCP manifest +const HiddenActionRequired = () => ; + +## Thread + +### Show code + {}} + onClose={() => {}} + onSend={() => {}} + onValueChange={() => {}} + referenceTag="Resource allocation" +/> + +### MCP manifest +const Thread = () => ; + +## Thread With Action Required + +### Show code + {}} + onClose={() => {}} + onSend={() => {}} + onValueChange={() => {}} + referenceTag="Resource allocation" +/> + +### MCP manifest +const ThreadWithActionRequired = () => ; + +# DsThreadItem docs snippets + +## Default + +### Show code + {}} + onEdit={() => {}} + onMarkUnread={() => {}} + onResolved={() => {}} +/> + +### MCP manifest +const Default = () => ; + +## Current User Message + +### Show code + {}} + onEdit={() => {}} + onMarkUnread={() => {}} + onResolved={() => {}} +/> + +### MCP manifest +const CurrentUserMessage = () => ; + +## Long Message + +### Show code + {}} + onEdit={() => {}} + onMarkUnread={() => {}} + onResolved={() => {}} +/> + +### MCP manifest +const LongMessage = () => ; + +## Multiline Message + +### Show code + {}} + onEdit={() => {}} + onMarkUnread={() => {}} + onResolved={() => {}} +/> + +### MCP manifest +const MultilineMessage = () => ; + +## No Avatar + +### Show code + {}} + onEdit={() => {}} + onMarkUnread={() => {}} + onResolved={() => {}} +/> + +### MCP manifest +const NoAvatar = () => ; + +## Read Only + +### Show code +{ + render: () => +} + +### MCP manifest +const ReadOnly = () => ( + +); \ No newline at end of file diff --git a/packages/design-system/src/components/ds-comment-bubble/components/ds-thread-item/__tests__/ds-thread-item.browser.test.tsx b/packages/design-system/src/components/ds-comment-bubble/components/ds-thread-item/__tests__/ds-thread-item.browser.test.tsx new file mode 100644 index 000000000..9d4799290 --- /dev/null +++ b/packages/design-system/src/components/ds-comment-bubble/components/ds-thread-item/__tests__/ds-thread-item.browser.test.tsx @@ -0,0 +1,347 @@ +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { DsThreadItem, type DsThreadItemProps } from '../index'; +import { DsButton } from '../../../../ds-button'; + +const mockAuthor = { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/150?img=1', +}; + +const INITIAL_CONTENT = 'Initial message content.'; +const UPDATED_CONTENT = 'Content updated from outside.'; + +const getDefaultArgs = (overrides: Partial = {}): DsThreadItemProps => ({ + id: 'msg-1', + author: mockAuthor, + content: 'This is a sample message in the comment thread.', + createdAt: new Date(Date.now() - 3600000), + isCommentAuthorMessage: true, + canModify: true, + onEdit: vi.fn(), + onDelete: vi.fn(), + onMarkUnread: vi.fn(), + onResolved: vi.fn(), + ...overrides, +}); + +describe('DsThreadItem', () => { + it('should render author name, content, and timestamp', async () => { + await page.render(); + + await expect.element(page.getByText('Karen J.')).toBeInTheDocument(); + await expect + .element(page.getByText('This is a sample message in the comment thread.')) + .toBeInTheDocument(); + await expect.element(page.getByText(/ago/i)).toBeInTheDocument(); + }); + + it('should render content for a current user message', async () => { + await page.render( + , + ); + + await expect + .element(page.getByText('This is my message, so it appears aligned to the right.')) + .toBeInTheDocument(); + }); + + it('should render a long message', async () => { + await page.render( + , + ); + + await expect.element(page.getByText(/adjusting the timeline/i)).toBeInTheDocument(); + }); + + it('should show initials and name when author has no avatar', async () => { + await page.render( + , + ); + + await expect.element(page.getByText('JD')).toBeInTheDocument(); + await expect.element(page.getByText('John Doe')).toBeInTheDocument(); + }); + + it('should show a recent timestamp for a message posted seconds ago', async () => { + await page.render( + , + ); + + await expect.element(page.getByText(/just now|ago/i)).toBeInTheDocument(); + }); + + it('should show a day-based timestamp for an old message', async () => { + await page.render( + , + ); + + await expect.element(page.getByText(/\d+d ago/i)).toBeInTheDocument(); + }); + + it('should render a multiline message', async () => { + await page.render( + , + ); + + await expect.element(page.getByText(/Line 1: First line/i)).toBeInTheDocument(); + }); + + it('should call onEdit with the message id and new content when saving an edit', async () => { + const onEdit = vi.fn(); + + await page.render(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /edit/i })); + + const textarea = page.getByRole('textbox'); + await expect.element(textarea).toHaveValue('This is a sample message in the comment thread.'); + + await userEvent.clear(textarea); + await userEvent.type(textarea, 'Updated message content'); + + await userEvent.click(page.getByRole('button', { name: /save/i })); + + expect(onEdit).toHaveBeenCalledWith('msg-1', 'Updated message content'); + }); + + it('should disable the save button when the edited content is empty', async () => { + await page.render(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /edit/i })); + + const textarea = page.getByRole('textbox'); + await userEvent.clear(textarea); + + await expect.element(page.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('should disable the save button when the content is unchanged', async () => { + await page.render(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /edit/i })); + + await expect.element(page.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('should call onDelete with the message id when the delete action is clicked', async () => { + const onDelete = vi.fn(); + + await page.render(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /delete/i })); + + expect(onDelete).toHaveBeenCalledWith('msg-1'); + }); + + it('should call onMarkUnread with the message id when the mark as action is clicked', async () => { + const onMarkUnread = vi.fn(); + + await page.render(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /mark as/i })); + + expect(onMarkUnread).toHaveBeenCalledWith('msg-1'); + }); + + it('should call onResolved with the message id when the resolve button is clicked', async () => { + const onResolved = vi.fn(); + + await page.render(); + + await userEvent.click(page.getByRole('button', { name: /mark message as resolved/i })); + + expect(onResolved).toHaveBeenCalledWith('msg-1'); + }); + + it('should not render action buttons when the viewer cannot modify', async () => { + await page.render( + , + ); + + await expect.element(page.getByText('Karen J.')).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: /more actions/i })).not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /mark message as resolved/i })) + .not.toBeInTheDocument(); + }); + + it('should reflect external content changes while not editing', async () => { + function Wrapper() { + const [content, setContent] = useState(INITIAL_CONTENT); + + return ( + <> + + + setContent(UPDATED_CONTENT)}> + Simulate external update + + + ); + } + + await page.render(); + + await expect.element(page.getByText(INITIAL_CONTENT)).toBeInTheDocument(); + + await userEvent.click(page.getByRole('button', { name: /simulate external update/i })); + + await expect.element(page.getByText(UPDATED_CONTENT)).toBeInTheDocument(); + await expect.element(page.getByText(INITIAL_CONTENT)).not.toBeInTheDocument(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /edit/i })); + + await expect.element(page.getByRole('textbox')).toHaveValue(UPDATED_CONTENT); + }); + + it('should keep the in-progress edit when content changes while editing', async () => { + function Wrapper() { + const [content, setContent] = useState(INITIAL_CONTENT); + + return ( + <> + + + setContent(UPDATED_CONTENT)}> + Simulate external update + + + ); + } + + await page.render(); + + await expect.element(page.getByText(INITIAL_CONTENT)).toBeInTheDocument(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /edit/i })); + + const textarea = page.getByRole('textbox'); + await expect.element(textarea).toHaveValue(INITIAL_CONTENT); + + await userEvent.clear(textarea); + await userEvent.type(textarea, 'My custom edit'); + + await expect.element(textarea).toHaveValue('My custom edit'); + + await userEvent.click(page.getByRole('button', { name: /simulate external update/i })); + + await expect.element(textarea).toHaveValue('My custom edit'); + }); + + it('should revert to the updated content after cancelling an edit that overlapped an external change', async () => { + function Wrapper() { + const [content, setContent] = useState(INITIAL_CONTENT); + + return ( + <> + + + setContent(UPDATED_CONTENT)}> + Simulate external update + + + ); + } + + await page.render(); + + await userEvent.click(page.getByRole('button', { name: /more actions/i })); + await userEvent.click(page.getByRole('menuitem', { name: /edit/i })); + + const textarea = page.getByRole('textbox'); + await userEvent.clear(textarea); + await userEvent.type(textarea, 'My custom edit'); + + await userEvent.click(page.getByRole('button', { name: /simulate external update/i })); + + await expect.element(textarea).toHaveValue('My custom edit'); + + await userEvent.click(page.getByRole('button', { name: /cancel/i })); + + await expect.element(page.getByText(UPDATED_CONTENT)).toBeInTheDocument(); + await expect.element(page.getByText(INITIAL_CONTENT)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/ds-comment-bubble/components/ds-thread-item/ds-thread-item.stories.tsx b/packages/design-system/src/components/ds-comment-bubble/components/ds-thread-item/ds-thread-item.stories.tsx index 910420a68..810753233 100644 --- a/packages/design-system/src/components/ds-comment-bubble/components/ds-thread-item/ds-thread-item.stories.tsx +++ b/packages/design-system/src/components/ds-comment-bubble/components/ds-thread-item/ds-thread-item.stories.tsx @@ -1,428 +1,104 @@ -import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn, expect, screen, userEvent, waitFor, within } from 'storybook/test'; -import { DsThreadItem, type DsThreadItemProps } from './index'; -import { DsButton } from '../../../ds-button'; +import { fn } from 'storybook/test'; +import { DsThreadItem } from './index'; const meta: Meta = { title: 'Components/Comments/ThreadItem', component: DsThreadItem, parameters: { layout: 'padded', - docs: { - description: { - component: ` -Individual message item within a comment thread. -Displays the author, timestamp, message content, and action buttons. - `, - }, - }, }, argTypes: { - id: { - control: 'text', - description: 'Unique identifier for the message', - }, - author: { - control: 'object', - description: 'Message author information (name, avatar)', - }, - content: { - control: 'text', - description: 'Message content text', - }, - createdAt: { - control: 'date', - description: 'When the message was created', - }, - isCommentAuthorMessage: { - control: 'boolean', - description: 'Whether this message is from the comment author (left-aligned)', - }, - canModify: { - control: 'boolean', - description: 'Whether the current user can modify this message', - }, - onEdit: { - description: 'Callback when message is edited', - }, - onDelete: { - description: 'Callback when message is deleted', - }, - onMarkUnread: { - description: 'Callback for mark as unread action', - }, - onResolved: { - description: 'Callback for resolved action (check circle)', - }, + isCommentAuthorMessage: { control: 'boolean' }, + canModify: { control: 'boolean' }, + content: { control: 'text' }, + className: { table: { disable: true } }, + }, + args: { + id: 'msg-1', + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/150?img=1' }, + content: 'This is a sample message in the comment thread.', + // Fixed timestamp keeps the serialized docs snippets deterministic. + createdAt: new Date('2026-02-09T09:00:00Z'), + isCommentAuthorMessage: true, + canModify: true, + onEdit: fn(), + onDelete: fn(), + onMarkUnread: fn(), + onResolved: fn(), }, }; export default meta; type Story = StoryObj; -const mockAuthor = { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/150?img=1', -}; - -const INITIAL_CONTENT = 'Initial message content.'; -const UPDATED_CONTENT = 'Content updated from outside.'; - -const defaultArgs: Partial = { - id: 'msg-1', - author: mockAuthor, - content: 'This is a sample message in the comment thread.', - createdAt: new Date(Date.now() - 3600000), - isCommentAuthorMessage: true, - canModify: true, - onEdit: fn(), - onDelete: fn(), - onMarkUnread: fn(), - onResolved: fn(), -}; - +/** + * A message from the thread's comment author, left-aligned. With `canModify` the + * viewer sees the more-actions menu and resolve control. + */ export const Default: Story = { - args: defaultArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText('Karen J.')).toBeInTheDocument(); - - await expect(canvas.getByText('This is a sample message in the comment thread.')).toBeInTheDocument(); - - const timestamp = canvas.getByText(/ago/i); - await expect(timestamp).toBeInTheDocument(); + args: { + isCommentAuthorMessage: true, }, }; +/** + * A message from the current viewer is right-aligned. Set `isCommentAuthorMessage` + * to `false` for replies that are not from the top-level comment author. + */ export const CurrentUserMessage: Story = { args: { - ...defaultArgs, id: 'msg-2', isCommentAuthorMessage: false, - canModify: false, - content: 'This is my message, so it appears aligned to the right.', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect( - canvas.getByText('This is my message, so it appears aligned to the right.'), - ).toBeInTheDocument(); + content: 'This is my reply, so it appears aligned to the right.', }, }; +/** + * Long content wraps within the item's width. + */ export const LongMessage: Story = { args: { - ...defaultArgs, id: 'msg-3', content: 'I think we should consider adjusting the timeline to ensure we have enough resources for the development phase. This will help us maintain quality standards and meet all the project requirements.', }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText(/adjusting the timeline/i)).toBeInTheDocument(); - }, }; -export const NoAvatar: Story = { +/** + * Multi-line content preserves its line breaks. + */ +export const MultilineMessage: Story = { args: { - ...defaultArgs, id: 'msg-4', - author: { - id: 'user-2', - name: 'John Doe', - avatarSrc: undefined, - }, - content: 'Message from a user without an avatar.', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText('JD')).toBeInTheDocument(); - - await expect(canvas.getByText('John Doe')).toBeInTheDocument(); + content: 'Line 1: First line of the message\nLine 2: Second line with more details\nLine 3: Final line', }, }; -export const RecentMessage: Story = { +/** + * When the author has no `avatarSrc`, the avatar falls back to initials derived + * from the name. + */ +export const NoAvatar: Story = { args: { - ...defaultArgs, id: 'msg-5', - createdAt: new Date(Date.now() - 30000), - content: 'Just posted this message.', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const timestamp = canvas.getByText(/just now|ago/i); - await expect(timestamp).toBeInTheDocument(); - }, -}; - -export const OldMessage: Story = { - args: { - ...defaultArgs, - id: 'msg-6', - createdAt: new Date(Date.now() - 86400000 * 3), - content: 'This message was posted a few days ago.', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const timestamp = canvas.getByText(/\d+d ago/i); - await expect(timestamp).toBeInTheDocument(); - }, -}; - -export const MultilineMessage: Story = { - args: { - ...defaultArgs, - id: 'msg-7', - content: `Line 1: First line of the message -Line 2: Second line with more details -Line 3: Final line with conclusion`, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText(/Line 1: First line/i)).toBeInTheDocument(); - }, -}; - -export const EditAndSave: Story = { - args: defaultArgs, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /edit/i })); - - const textarea = canvas.getByRole('textbox'); - await expect(textarea).toHaveValue('This is a sample message in the comment thread.'); - - await userEvent.clear(textarea); - await userEvent.type(textarea, 'Updated message content'); - - await userEvent.click(canvas.getByRole('button', { name: /save/i })); - - await expect(args.onEdit).toHaveBeenCalledWith('msg-1', 'Updated message content'); - }, -}; - -export const EditSaveDisabledWhenEmpty: Story = { - args: defaultArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /edit/i })); - - const textarea = canvas.getByRole('textbox'); - await userEvent.clear(textarea); - - const saveButton = canvas.getByRole('button', { name: /save/i }); - await expect(saveButton).toBeDisabled(); - }, -}; - -export const EditSaveDisabledWhenUnchanged: Story = { - args: defaultArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /edit/i })); - - const saveButton = canvas.getByRole('button', { name: /save/i }); - await expect(saveButton).toBeDisabled(); - }, -}; - -export const DeleteAction: Story = { - args: defaultArgs, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /delete/i })); - - await expect(args.onDelete).toHaveBeenCalledWith('msg-1'); - }, -}; - -export const MarkUnreadAction: Story = { - args: defaultArgs, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /mark as/i })); - - await expect(args.onMarkUnread).toHaveBeenCalledWith('msg-1'); - }, -}; - -export const ResolvedAction: Story = { - args: defaultArgs, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const resolvedButton = canvas.getByRole('button', { name: /mark message as resolved/i }); - - await userEvent.click(resolvedButton); - - await expect(args.onResolved).toHaveBeenCalledWith('msg-1'); - }, -}; - -export const NoActionsWhenCannotModify: Story = { - args: { - ...defaultArgs, - canModify: false, - onEdit: undefined, - onDelete: undefined, - onMarkUnread: undefined, - onResolved: undefined, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.queryByRole('button', { name: /more actions/i })).not.toBeInTheDocument(); - await expect(canvas.queryByRole('button', { name: /mark message as resolved/i })).not.toBeInTheDocument(); - }, -}; - -export const ContentChangeWhileNotEditing: Story = { - render: function Render() { - const [content, setContent] = useState(INITIAL_CONTENT); - - return ( - <> - - - setContent(UPDATED_CONTENT)}> - Simulate external update - - - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText(INITIAL_CONTENT)).toBeInTheDocument(); - - await userEvent.click(canvas.getByRole('button', { name: /simulate external update/i })); - - await expect(canvas.getByText(UPDATED_CONTENT)).toBeInTheDocument(); - await expect(canvas.queryByText(INITIAL_CONTENT)).not.toBeInTheDocument(); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /edit/i })); - - const textarea = canvas.getByRole('textbox'); - await expect(textarea).toHaveValue(UPDATED_CONTENT); - }, -}; - -export const ContentChangeWhileEditing: Story = { - render: function Render() { - const [content, setContent] = useState(INITIAL_CONTENT); - - return ( - <> - - - setContent(UPDATED_CONTENT)}> - Simulate external update - - - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText(INITIAL_CONTENT)).toBeInTheDocument(); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /edit/i })); - - const textarea = canvas.getByRole('textbox'); - await expect(textarea).toHaveValue(INITIAL_CONTENT); - - await userEvent.clear(textarea); - await userEvent.type(textarea, 'My custom edit'); - - await expect(textarea).toHaveValue('My custom edit'); - - await userEvent.click(canvas.getByRole('button', { name: /simulate external update/i })); - - await expect(textarea).toHaveValue('My custom edit'); + author: { id: 'user-2', name: 'John Doe' }, + content: 'Message from a user without an avatar.', }, }; -export const ContentChangeWhileEditingThenCancel: Story = { - render: function Render() { - const [content, setContent] = useState(INITIAL_CONTENT); - - return ( - <> - - - setContent(UPDATED_CONTENT)}> - Simulate external update - - - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await userEvent.click(canvas.getByRole('button', { name: /more actions/i })); - await userEvent.click(screen.getByRole('menuitem', { name: /edit/i })); - - const textarea = canvas.getByRole('textbox'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'My custom edit'); - - await userEvent.click(canvas.getByRole('button', { name: /simulate external update/i })); - - await expect(textarea).toHaveValue('My custom edit'); - - await userEvent.click(canvas.getByRole('button', { name: /cancel/i })); - - await waitFor(async () => { - await expect(canvas.getByText(UPDATED_CONTENT)).toBeInTheDocument(); - }); - await expect(canvas.queryByText(INITIAL_CONTENT)).not.toBeInTheDocument(); - }, +/** + * A read-only message: without `canModify` and action callbacks, neither the + * more-actions menu nor the resolve control is rendered. + */ +export const ReadOnly: Story = { + render: () => ( + + ), }; diff --git a/packages/design-system/src/components/ds-comment-bubble/ds-comment-bubble.stories.module.scss b/packages/design-system/src/components/ds-comment-bubble/ds-comment-bubble.stories.module.scss deleted file mode 100644 index 92870117e..000000000 --- a/packages/design-system/src/components/ds-comment-bubble/ds-comment-bubble.stories.module.scss +++ /dev/null @@ -1,33 +0,0 @@ -.grid { - display: flex; - flex-direction: column; - gap: var(--xl); -} - -.column { - display: flex; - flex-direction: column; - gap: var(--sm); -} - -.heading { - margin: 0; - font-size: 14px; - font-weight: 600; - color: var(--font-secondary); -} - -.interactiveContainer { - display: flex; - flex-direction: column; - gap: var(--standard); - align-items: center; -} - -.instructions { - margin: 0; - font-size: 14px; - color: var(--font-secondary); - text-align: center; - max-width: 400px; -} diff --git a/packages/design-system/src/components/ds-comment-bubble/ds-comment-bubble.stories.tsx b/packages/design-system/src/components/ds-comment-bubble/ds-comment-bubble.stories.tsx index 5dd716809..8979ec309 100644 --- a/packages/design-system/src/components/ds-comment-bubble/ds-comment-bubble.stories.tsx +++ b/packages/design-system/src/components/ds-comment-bubble/ds-comment-bubble.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; import { useState } from 'react'; +import { fn } from 'storybook/test'; import { DsCommentBubble } from './index'; +import { DsStack } from '../ds-stack'; +import { DsTypography } from '../ds-typography'; import type { CommentData, CommentAuthor } from '../ds-comment-card'; -import styles from './ds-comment-bubble.stories.module.scss'; const currentUser: CommentAuthor = { id: 'user-1', @@ -11,55 +12,26 @@ const currentUser: CommentAuthor = { avatarSrc: 'https://i.pravatar.cc/40?img=1', }; +// Fixed timestamps keep the serialized docs snippets deterministic. const createMockComment = (overrides: Partial = {}): CommentData => ({ id: 'comment-1', numericId: 63, author: currentUser, - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + createdAt: new Date('2026-02-09T09:00:00Z'), isResolved: false, messages: [ { id: 'msg-1', author: currentUser, content: 'We need to review the resource allocation for this project.', - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + createdAt: new Date('2026-02-09T09:00:00Z'), isInitialMessage: true, }, { id: 'msg-2', - author: currentUser, - content: - 'I think we should consider adjusting the timeline to ensure we have enough resources for the development phase. This will help us maintain quality standards.', - createdAt: new Date(Date.now() - 20 * 60 * 60 * 1000), - }, - { - id: 'msg-3', - author: { - id: 'user-2', - name: 'John D.', - avatarSrc: 'https://i.pravatar.cc/40?img=2', - }, - content: - 'That makes sense. I can help coordinate with the team to identify any potential blockers. We should also check with stakeholders about priority.', - createdAt: new Date(Date.now() - 16 * 60 * 60 * 1000), - }, - { - id: 'msg-4', - author: currentUser, - content: - 'Great idea. Let me schedule a meeting with the stakeholders for next week. We can discuss the timeline and resource requirements in detail.', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), - }, - { - id: 'msg-5', - author: { - id: 'user-3', - name: 'Jane S.', - avatarSrc: 'https://i.pravatar.cc/40?img=3', - }, - content: - 'I can prepare a summary of our current resource usage and projected needs for the meeting. This will help us make informed decisions.', - createdAt: new Date(Date.now() - 8 * 60 * 60 * 1000), + author: { id: 'user-2', name: 'John D.', avatarSrc: 'https://i.pravatar.cc/40?img=2' }, + content: 'That makes sense. I can help coordinate with the team to identify blockers.', + createdAt: new Date('2026-02-09T11:00:00Z'), }, ], ...overrides, @@ -70,82 +42,51 @@ const meta: Meta = { component: DsCommentBubble, parameters: { layout: 'centered', - docs: { - description: { - component: ` -Floating bubble component for creating new comments and viewing/replying to existing threads. - -**Behavior-driven states:** -- **Initial**: Empty bubble (240px) with placeholder "Add a comment" and disabled send button -- **Typing**: Expands to 320px when user focuses or types, shows footer with action required checkbox -- **Thread**: Full thread view (420px) with messages, header, and reply input - -**Features:** -- Auto-resizing textarea (min 40px, max 480px) -- Action required checkbox with orange styling -- Thread view with scrollable messages (max 540px) -- Edit/delete message actions on hover -- More actions dropdown menu -- Keyboard support (Enter to send, Shift+Enter for new line) -- Automatic state transitions based on user interaction - `, - }, - }, }, argTypes: { - hideActionRequired: { - control: 'boolean', - description: 'Whether to hide action required controls', - }, - actionRequired: { - control: 'boolean', - description: 'Whether action required is checked', - }, - value: { - control: 'text', - description: 'Current input value', - }, - comment: { - table: { disable: true }, - }, - currentUser: { - table: { disable: true }, - }, + hideActionRequired: { control: 'boolean' }, + actionRequired: { control: 'boolean' }, + value: { control: 'text' }, + comment: { table: { disable: true } }, + currentUser: { table: { disable: true } }, + className: { table: { disable: true } }, + style: { table: { disable: true } }, }, args: { referenceTag: 'Resource allocation', onSend: fn(), onClose: fn(), - onResolve: fn(), - onToggleActionRequired: fn(), - onForward: fn(), - onMarkUnread: fn(), - onCopyLink: fn(), - onDelete: fn(), - onActionRequiredChange: fn(), onValueChange: fn(), - onEditMessage: fn(), - onDeleteMessage: fn(), - onMessageMarkUnread: fn(), - onMessageResolved: fn(), + onActionRequiredChange: fn(), }, }; export default meta; type Story = StoryObj; +/** + * Empty bubble for composing a new comment. The send button stays disabled until + * the viewer types. + */ export const Initial: Story = { args: { value: '', }, }; +/** + * As the viewer types, the bubble expands and reveals the action-required checkbox + * and an enabled send button. + */ export const Typing: Story = { args: { value: 'This is a new comment...', }, }; +/** + * The composer with the action-required flag checked. + */ export const TypingWithActionRequired: Story = { args: { value: 'This needs attention!', @@ -153,184 +94,165 @@ export const TypingWithActionRequired: Story = { }, }; -export const TypingWithoutActionRequired: Story = { +/** + * Hide the action-required affordance entirely with `hideActionRequired` when the + * flow does not use it. + */ +export const HiddenActionRequired: Story = { args: { value: 'This is a new comment...', hideActionRequired: true, }, }; +/** + * Existing thread view. Pass a `comment` and the `currentUser` to render messages + * with a reply composer. + */ export const Thread: Story = { args: { - comment: createMockComment(), - currentUser, + currentUser: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + comment: { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-09T09:00:00Z'), + isResolved: false, + messages: [ + { + id: 'msg-1', + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: 'We need to review the resource allocation for this project.', + createdAt: new Date('2026-02-09T09:00:00Z'), + isInitialMessage: true, + }, + { + id: 'msg-2', + author: { id: 'user-2', name: 'John D.', avatarSrc: 'https://i.pravatar.cc/40?img=2' }, + content: 'That makes sense. I can help coordinate with the team to identify blockers.', + createdAt: new Date('2026-02-09T11:00:00Z'), + }, + ], + }, }, }; +/** + * A thread flagged as requiring action shows the action-required treatment in the + * header. + */ export const ThreadWithActionRequired: Story = { args: { - comment: createMockComment(), actionRequired: true, - currentUser, - }, -}; - -export const ThreadWithoutActionRequired: Story = { - args: { - comment: createMockComment(), - currentUser, - hideActionRequired: true, - actionRequired: true, - }, -}; - -export const SendButtonClick: Story = { - args: { - value: 'Test message to send', - }, -}; - -export const SendWithEnterKey: Story = { - args: { - value: 'Enter key message', - }, -}; - -export const SendDisabledWhenEmpty: Story = { - args: { - value: '', - }, -}; - -export const ThreadSendDisabledWhenEmpty: Story = { - args: { - comment: createMockComment(), - currentUser, - value: '', - }, -}; - -export const ThreadSendEnabled: Story = { - args: { - comment: createMockComment(), - currentUser, - value: 'A reply', - }, -}; - -export const ThreadCloseButton: Story = { - args: { - comment: createMockComment(), - currentUser, - }, -}; - -export const ThreadResolveButton: Story = { - args: { - comment: createMockComment(), - currentUser, - }, -}; - -export const TextareaValueChange: Story = { - args: { - comment: createMockComment(), - currentUser, - value: '', + currentUser: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + comment: { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-09T09:00:00Z'), + isResolved: false, + messages: [ + { + id: 'msg-1', + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: 'We need to review the resource allocation for this project.', + createdAt: new Date('2026-02-09T09:00:00Z'), + isInitialMessage: true, + }, + ], + }, }, }; -export const InitialWithReferenceTag: Story = { - args: { - referenceTag: 'My tag', +/** + * The main bubble states side by side for visual comparison. + */ +export const AllStates: Story = { + tags: ['!manifest'], + parameters: { + docs: { canvas: { sourceState: 'none' } }, }, + render: () => ( + + + + Initial + + + + + + Typing + + + + + + Thread + + + + + ), }; -export const ThreadWithReferenceTag: Story = { - args: { - comment: createMockComment(), - currentUser, - referenceTag: 'Resource allocation', +/** + * Fully interactive flow: type to compose, send to create a thread, then add replies + * and edit or delete messages. + */ +export const Interactive: Story = { + tags: ['!manifest'], + parameters: { + docs: { canvas: { sourceState: 'none' } }, }, -}; - -export const FullInteractiveFlow: Story = { - render: function FullFlowStory() { + render: function InteractiveStory() { const [value, setValue] = useState(''); const [actionRequired, setActionRequired] = useState(false); const [comment, setComment] = useState(undefined); const handleSend = (content: string, isActionRequired: boolean) => { - if (!comment) { - const newComment: CommentData = { - id: 'comment-1', - numericId: 63, - author: currentUser, - createdAt: new Date(), - isResolved: false, - messages: [ - { - id: 'msg-1', - author: currentUser, - content, - createdAt: new Date(), - isInitialMessage: true, - }, - ], - }; - setComment(newComment); - setActionRequired(isActionRequired); - } else { - const newMessage = { + setComment((prev) => { + const message = { id: `msg-${String(Date.now())}`, author: currentUser, content, createdAt: new Date(), }; - setComment((prev) => { - if (!prev) { - return prev; - } + if (!prev) { + setActionRequired(isActionRequired); return { - ...prev, - messages: [...prev.messages, newMessage], + id: 'comment-1', + numericId: 63, + author: currentUser, + createdAt: new Date(), + isResolved: false, + messages: [{ ...message, isInitialMessage: true }], }; - }); - } + } + + return { ...prev, messages: [...prev.messages, message] }; + }); setValue(''); }; const handleEditMessage = (messageId: string, newContent: string) => { - if (!comment) { - return; - } - setComment((prev) => { - if (!prev) { - return prev; - } - return { - ...prev, - messages: prev.messages.map((msg) => - msg.id === messageId ? { ...msg, content: newContent } : msg, - ), - }; - }); + setComment((prev) => + prev + ? { + ...prev, + messages: prev.messages.map((msg) => + msg.id === messageId ? { ...msg, content: newContent } : msg, + ), + } + : prev, + ); }; const handleDeleteMessage = (messageId: string) => { - if (!comment) { - return; - } - setComment((prev) => { - if (!prev) { - return prev; - } - return { - ...prev, - messages: prev.messages.filter((msg) => msg.id !== messageId), - }; - }); + setComment((prev) => + prev ? { ...prev, messages: prev.messages.filter((msg) => msg.id !== messageId) } : prev, + ); }; const handleClose = () => { @@ -339,80 +261,6 @@ export const FullInteractiveFlow: Story = { setActionRequired(false); }; - return ( -
-

- Full Flow Test: -
- 1. Start by typing in the bubble - it will expand and show the footer -
- 2. Send your first comment to create a thread -
- 3. Add more replies to see the thread grow -
- 4. Close to reset and start over -

- console.log('Mark unread message:', id)} - onMessageResolved={(id) => console.log('Resolved message:', id)} - onClose={handleClose} - onResolve={() => console.log('Resolve clicked')} - onToggleActionRequired={() => console.log('Toggle action required')} - onForward={() => console.log('Forward')} - onMarkUnread={() => console.log('Mark unread')} - onCopyLink={() => console.log('Copy link')} - onDelete={() => console.log('Delete')} - /> -
- ); - }, -}; - -export const InteractiveThread: Story = { - render: function InteractiveThreadStory() { - const [value, setValue] = useState(''); - const [actionRequired, setActionRequired] = useState(false); - const [comment, setComment] = useState(createMockComment()); - - const handleSend = (content: string) => { - const newMessage = { - id: `msg-${String(Date.now())}`, - author: currentUser, - content, - createdAt: new Date(), - }; - - setComment((prev) => ({ - ...prev, - messages: [...prev.messages, newMessage], - })); - setValue(''); - }; - - const handleEditMessage = (messageId: string, newContent: string) => { - setComment((prev) => ({ - ...prev, - messages: prev.messages.map((msg) => (msg.id === messageId ? { ...msg, content: newContent } : msg)), - })); - }; - - const handleDeleteMessage = (messageId: string) => { - setComment((prev) => ({ - ...prev, - messages: prev.messages.filter((msg) => msg.id !== messageId), - })); - }; - return ( console.log('Mark unread message:', id)} - onMessageResolved={(id) => console.log('Resolved message:', id)} - onClose={() => console.log('Close clicked')} - onResolve={() => console.log('Resolve clicked')} - onToggleActionRequired={() => console.log('Toggle action required')} - onForward={() => console.log('Forward')} - onMarkUnread={() => console.log('Mark unread')} - onCopyLink={() => console.log('Copy link')} - onDelete={() => console.log('Delete')} + onMessageMarkUnread={fn()} + onMessageResolved={fn()} + onClose={handleClose} + onResolve={fn()} + onToggleActionRequired={fn()} + onForward={fn()} + onMarkUnread={fn()} + onCopyLink={fn()} + onDelete={fn()} /> ); }, }; - -export const AllTypes: Story = { - render: () => ( -
-
-

Initial

- -
- -
-

Typing

- -
- -
-

Typing (Action Required)

- -
- -
-

Thread

- -
- -
-

Thread (Action Required)

- -
-
- ), -}; diff --git a/packages/design-system/src/components/ds-comment-card/__tests__/__snapshots__/ds-comment-card.docs.snap b/packages/design-system/src/components/ds-comment-card/__tests__/__snapshots__/ds-comment-card.docs.snap new file mode 100644 index 000000000..64b53178a --- /dev/null +++ b/packages/design-system/src/components/ds-comment-card/__tests__/__snapshots__/ds-comment-card.docs.snap @@ -0,0 +1,365 @@ +# DsCommentCard docs snippets + +## Default + +### Show code + {}} + onDelete={() => {}} + onResolve={() => {}} +/> + +### MCP manifest +const Default = () => ; + +## Action Required + +### Show code + {}} + onDelete={() => {}} + onResolve={() => {}} +/> + +### MCP manifest +const ActionRequired = () => ; + +## Disabled + +### Show code + {}} + onDelete={() => {}} + onResolve={() => {}} +/> + +### MCP manifest +const Disabled = () => ; + +## Full Message + +### Show code + {}} + onDelete={() => {}} + onResolve={() => {}} + overflow="displayed" +/> + +### MCP manifest +const FullMessage = () => ; + +## With Reference Tag + +### Show code + {}} + onDelete={() => {}} + onResolve={() => {}} +/> + +### MCP manifest +const WithReferenceTag = () => ; + +## Custom Formatter + +### Show code +{ + parameters: { + docs: { + source: { + type: 'code' + } + } + }, + render: args => date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} /> +} + +### MCP manifest +const CustomFormatter = () => + date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + } />; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-comment-card/__tests__/ds-comment-card.browser.test.tsx b/packages/design-system/src/components/ds-comment-card/__tests__/ds-comment-card.browser.test.tsx new file mode 100644 index 000000000..9de79831a --- /dev/null +++ b/packages/design-system/src/components/ds-comment-card/__tests__/ds-comment-card.browser.test.tsx @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { DsCommentCard } from '../index'; +import type { CommentData } from '../ds-comment-card.types'; + +const author = { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/40?img=1', +}; + +const createMockComment = (overrides: Partial = {}): CommentData => ({ + id: 'comment-1', + numericId: 63, + author, + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + isResolved: false, + messages: [ + { + id: 'msg-1', + author, + content: + 'We need to review the resource allocation for this project. I think we should consider adjusting the timeline to ensure we have enough resources for the development phase. This will help us maintain quality standards and meet all the project requirements efficiently.', + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + isInitialMessage: true, + }, + { + id: 'msg-2', + author: { + id: 'user-2', + name: 'John D.', + avatarSrc: 'https://i.pravatar.cc/40?img=2', + }, + content: 'Thanks for the feedback!', + createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), + }, + { + id: 'msg-3', + author: { + id: 'user-3', + name: 'Jane S.', + avatarSrc: 'https://i.pravatar.cc/40?img=3', + }, + content: 'I agree with this approach.', + createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000), + }, + { + id: 'msg-4', + author, + content: 'Great, let us proceed then.', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }, + ], + ...overrides, +}); + +describe('DsCommentCard', () => { + it('should render the card button with an aria-label, comment text, and reply count', async () => { + await page.render(); + + const card = page.getByRole('button', { name: /Comment #/i }); + + await expect.element(card).toBeInTheDocument(); + await expect.element(card).toHaveAttribute('aria-label'); + await expect.element(page.getByText(/resource allocation/)).toBeInTheDocument(); + await expect.element(page.getByText(/3 replies/i)).toBeInTheDocument(); + }); + + it('should render the action required affordance when isActionRequired is set', async () => { + await page.render( + , + ); + + const card = page.getByRole('button', { name: /action required/i }); + + await expect.element(card).toBeInTheDocument(); + }); + + it('should disable the card button when disabled', async () => { + await page.render(); + + const card = page.getByRole('button', { name: /Comment #/i }); + + await expect.element(card).toBeDisabled(); + }); + + it('should render the full comment text when overflow is displayed', async () => { + await page.render(); + + const card = page.getByRole('button', { name: /Comment #/i }); + + await expect.element(card).toBeInTheDocument(); + await expect.element(page.getByText(/resource allocation/)).toBeInTheDocument(); + }); + + it('should render a single-message comment', async () => { + await page.render( + , + ); + + const card = page.getByRole('button', { name: /Comment #/i }); + + await expect.element(card).toBeInTheDocument(); + await expect.element(page.getByText(/This is a short single message comment/)).toBeInTheDocument(); + }); + + it('should render the reference tag text', async () => { + await page.render( + , + ); + + await expect.element(page.getByText('Resource allocation', { exact: true })).toBeInTheDocument(); + }); + + it('should call onClick once when the card is clicked', async () => { + const onClick = vi.fn(); + + await page.render(); + + await page.getByRole('button', { name: /Comment #63/i }).click(); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('should render "1 reply" (singular) for a comment with exactly one reply', async () => { + await page.render( + , + ); + + await expect.element(page.getByText('1 reply')).toBeInTheDocument(); + }); + + it('should use a custom timestamp formatter to render an absolute date', async () => { + const formatTimestamp = (date: Date): string => + date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + await page.render( + , + ); + + const card = page.getByRole('button', { name: /Comment #/i }); + + await expect.element(card).toBeInTheDocument(); + + const cardText = card.element().textContent; + + expect(cardText).toMatch(/\d{4}/); + expect(cardText).not.toMatch(/ago/i); + }); +}); diff --git a/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.module.scss b/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.module.scss deleted file mode 100644 index eb5a59e42..000000000 --- a/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.module.scss +++ /dev/null @@ -1,23 +0,0 @@ -.decorator { - width: 484px; -} - -.grid { - display: flex; - flex-direction: column; - gap: var(--lg); - width: 484px; -} - -.column { - display: flex; - flex-direction: column; - gap: var(--xs); -} - -.heading { - margin: 0; - font-size: 14px; - font-weight: 600; - color: var(--font-secondary); -} diff --git a/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx b/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx index d14a7899c..1ed9ae784 100644 --- a/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx +++ b/packages/design-system/src/components/ds-comment-card/ds-comment-card.stories.tsx @@ -1,61 +1,37 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, fn, userEvent, within } from 'storybook/test'; +import { fn } from 'storybook/test'; import { DsCommentCard } from './index'; +import { DsStack } from '../ds-stack'; +import { DsTypography } from '../ds-typography'; import type { CommentData } from './ds-comment-card.types'; -import styles from './ds-comment-card.stories.module.scss'; +const author = { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/40?img=1', +}; + +// Shared builder for the visual-only showcase (excluded from docs snippets). Manifest +// stories inline their comment literal instead so the snippets stay ready to copy. const createMockComment = (overrides: Partial = {}): CommentData => ({ id: 'comment-1', numericId: 63, - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + author, + createdAt: new Date('2026-02-09T10:00:00Z'), isResolved: false, messages: [ { id: 'msg-1', - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - content: - 'We need to review the resource allocation for this project. I think we should consider adjusting the timeline to ensure we have enough resources for the development phase. This will help us maintain quality standards and meet all the project requirements efficiently.', - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + author, + content: 'We need to review the resource allocation for this project before the next sprint.', + createdAt: new Date('2026-02-09T10:00:00Z'), isInitialMessage: true, }, { id: 'msg-2', - author: { - id: 'user-2', - name: 'John D.', - avatarSrc: 'https://i.pravatar.cc/40?img=2', - }, + author: { id: 'user-2', name: 'John D.', avatarSrc: 'https://i.pravatar.cc/40?img=2' }, content: 'Thanks for the feedback!', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), - }, - { - id: 'msg-3', - author: { - id: 'user-3', - name: 'Jane S.', - avatarSrc: 'https://i.pravatar.cc/40?img=3', - }, - content: 'I agree with this approach.', - createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000), - }, - { - id: 'msg-4', - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - content: 'Great, let us proceed then.', - createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + createdAt: new Date('2026-02-09T12:00:00Z'), }, ], ...overrides, @@ -69,68 +45,23 @@ const meta: Meta = { }, decorators: [ (Story) => ( -
+ -
+ ), ], argTypes: { - comment: { - description: 'Comment data including author, messages, participants, and metadata', - }, - disabled: { - control: 'boolean', - description: 'Whether the card is disabled', - }, overflow: { control: 'select', options: ['hidden', 'displayed'], - description: 'Whether to truncate long messages or show them in full', - }, - onClick: { - action: 'clicked', - description: 'Callback when card is clicked', - }, - onResolve: { - action: 'resolved', - description: 'Callback when resolve button is clicked', - }, - onToggleActionRequired: { - action: 'toggle-action-required', - description: 'Callback when action required is toggled', - }, - onForward: { - action: 'forward', - description: 'Callback when forward action is triggered', - }, - onMarkUnread: { - action: 'mark-unread', - description: 'Callback when mark as unread is triggered', - }, - onCopyLink: { - action: 'copy-link', - description: 'Callback when copy link is triggered', - }, - onDelete: { - action: 'delete', - description: 'Callback when delete is triggered', - }, - formatTimestamp: { - description: - 'Custom formatter for timestamps. Defaults to relative time format (e.g., "2d ago"). Can be overridden for custom formats like absolute dates.', - table: { - type: { summary: '(date: Date) => string' }, - defaultValue: { summary: 'formatRelativeTime' }, - }, }, + comment: { table: { disable: true } }, + className: { table: { disable: true } }, + style: { table: { disable: true } }, }, args: { onClick: fn(), onResolve: fn(), - onToggleActionRequired: fn(), - onForward: fn(), - onMarkUnread: fn(), - onCopyLink: fn(), onDelete: fn(), }, }; @@ -138,210 +69,207 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const DefaultCard: Story = { +/** + * Standard card summarizing a thread: author, relative timestamp, message preview, + * and reply count. Long previews truncate by default. + */ +export const Default: Story = { args: { - comment: createMockComment(), - overflow: 'hidden', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const card = canvas.getByRole('button', { name: /Comment #/i }); - - await expect(card).toBeInTheDocument(); - await expect(card).toHaveAttribute('aria-label'); - - const commentText = canvas.getByText(/resource allocation/); - await expect(commentText).toBeInTheDocument(); - - const replyCount = canvas.getByText(/3 replies/i); - await expect(replyCount).toBeInTheDocument(); + comment: { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-09T10:00:00Z'), + isResolved: false, + messages: [ + { + id: 'msg-1', + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: 'We need to review the resource allocation for this project before the next sprint.', + createdAt: new Date('2026-02-09T10:00:00Z'), + isInitialMessage: true, + }, + { + id: 'msg-2', + author: { id: 'user-2', name: 'John D.', avatarSrc: 'https://i.pravatar.cc/40?img=2' }, + content: 'Thanks for the feedback!', + createdAt: new Date('2026-02-09T12:00:00Z'), + }, + ], + }, }, }; +/** + * Flagged card. Set `isActionRequired` on the comment to surface the action-required + * treatment so it stands out in a list. + */ export const ActionRequired: Story = { args: { - comment: createMockComment({ isActionRequired: true }), - overflow: 'hidden', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const card = canvas.getByRole('button', { name: /action required/i }); - - await expect(card).toBeInTheDocument(); + comment: { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-09T10:00:00Z'), + isResolved: false, + isActionRequired: true, + messages: [ + { + id: 'msg-1', + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: 'We need to review the resource allocation for this project before the next sprint.', + createdAt: new Date('2026-02-09T10:00:00Z'), + isInitialMessage: true, + }, + ], + }, }, }; -export const DisabledState: Story = { +/** + * Non-interactive card. Use while an operation is in flight or when the thread is + * read-only. + */ +export const Disabled: Story = { args: { - comment: createMockComment(), disabled: true, - overflow: 'hidden', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const card = canvas.getByRole('button', { name: /Comment #/i }); - - await expect(card).toBeInTheDocument(); - await expect(card).toBeDisabled(); + comment: { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-09T10:00:00Z'), + isResolved: false, + messages: [ + { + id: 'msg-1', + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: 'We need to review the resource allocation for this project before the next sprint.', + createdAt: new Date('2026-02-09T10:00:00Z'), + isInitialMessage: true, + }, + ], + }, }, }; +/** + * Show the full message body instead of a truncated preview with `overflow="displayed"`. + */ export const FullMessage: Story = { args: { - comment: createMockComment(), overflow: 'displayed', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const card = canvas.getByRole('button', { name: /Comment #/i }); - const commentText = canvas.getByText(/resource allocation/); - - await expect(card).toBeInTheDocument(); - await expect(commentText).toBeInTheDocument(); - }, -}; - -export const SingleMessage: Story = { - args: { - comment: createMockComment({ + comment: { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-09T10:00:00Z'), + isResolved: false, messages: [ { id: 'msg-1', - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - content: 'This is a short single message comment.', - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: + 'We need to review the resource allocation for this project. Adjusting the timeline will ensure we have enough resources for the development phase and keep quality high.', + createdAt: new Date('2026-02-09T10:00:00Z'), isInitialMessage: true, }, ], - }), - overflow: 'hidden', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const card = canvas.getByRole('button', { name: /Comment #/i }); - const commentText = canvas.getByText(/This is a short single message comment/); - - await expect(card).toBeInTheDocument(); - await expect(commentText).toBeInTheDocument(); - }, -}; - -export const Default: Story = { - render: () => ( -
-
-

Default

- -
- -
-

Action Required

- -
- -
-

Disabled

- -
- -
-

Full Message

- -
-
- ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const cards = canvas.getAllByRole('button', { name: /Comment #/i }); - - await expect(cards.length).toBeGreaterThan(0); + }, }, }; +/** + * A reference chip in the header links the thread back to the entity it annotates. + */ export const WithReferenceTag: Story = { args: { - comment: createMockComment({ referenceTag: 'Resource allocation' }), - overflow: 'hidden', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText('Resource allocation')).toBeInTheDocument(); - }, -}; - -export const WithCallbacks: Story = { - args: { - comment: createMockComment(), - overflow: 'hidden', - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const card = canvas.getByRole('button', { name: /Comment #63/i }); - - await userEvent.click(card); - - await expect(args.onClick).toHaveBeenCalledOnce(); - }, -}; - -export const SingleReply: Story = { - args: { - comment: createMockComment({ + comment: { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-09T10:00:00Z'), + isResolved: false, + referenceTag: 'Resource allocation', messages: [ { id: 'msg-1', - author: { id: 'user-1', name: 'Karen J.' }, - content: 'Initial message', - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: 'We need to review the resource allocation for this project before the next sprint.', + createdAt: new Date('2026-02-09T10:00:00Z'), isInitialMessage: true, }, - { - id: 'msg-2', - author: { id: 'user-2', name: 'John D.' }, - content: 'One reply', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), - }, ], - }), - overflow: 'hidden', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText('1 reply')).toBeInTheDocument(); + }, }, }; +/** + * Override the default relative time via `formatTimestamp` — here an absolute date. + */ export const CustomFormatter: Story = { - args: { - comment: createMockComment(), - overflow: 'hidden', - formatTimestamp: (date: Date) => { - const options: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }; - return date.toLocaleDateString('en-US', options); - }, + parameters: { + docs: { source: { type: 'code' } }, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const card = canvas.getByRole('button', { name: /Comment #/i }); - - await expect(card).toBeInTheDocument(); + render: (args) => ( + + date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + } + /> + ), +}; - // Verify the formatted timestamp is present (should be in format like "Feb 9, 2026, 05:11 PM") - // and does not contain "ago" - const cardText = card.textContent || ''; - await expect(cardText).toMatch(/\d{4}/); // Should contain year - await expect(cardText).not.toMatch(/ago/i); // Should not contain "ago" +/** + * The main states side by side for visual comparison. + */ +export const AllStates: Story = { + tags: ['!manifest'], + parameters: { + docs: { canvas: { sourceState: 'none' } }, }, + render: () => ( + + + + Default + + + + + + Action required + + + + + + Disabled + + + + + + Full message + + + + + ), }; diff --git a/packages/design-system/src/components/ds-comment-indicator/__tests__/__snapshots__/ds-comment-indicator.docs.snap b/packages/design-system/src/components/ds-comment-indicator/__tests__/__snapshots__/ds-comment-indicator.docs.snap new file mode 100644 index 000000000..8928e4300 --- /dev/null +++ b/packages/design-system/src/components/ds-comment-indicator/__tests__/__snapshots__/ds-comment-indicator.docs.snap @@ -0,0 +1,39 @@ +# DsCommentIndicator docs snippets + +## Default + +### Show code + {}} + type="default" +/> + +### MCP manifest +const Default = () => ; + +## Placeholder + +### Show code + {}} + type="placeholder" +/> + +### MCP manifest +const Placeholder = () => ; + +## Action Required + +### Show code + {}} + type="action-required" +/> + +### MCP manifest +const ActionRequired = () => ; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-comment-indicator/__tests__/ds-comment-indicator.browser.test.tsx b/packages/design-system/src/components/ds-comment-indicator/__tests__/ds-comment-indicator.browser.test.tsx new file mode 100644 index 000000000..9f5b5141a --- /dev/null +++ b/packages/design-system/src/components/ds-comment-indicator/__tests__/ds-comment-indicator.browser.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { DsCommentIndicator } from '../index'; + +describe('DsCommentIndicator', () => { + it('should render placeholder type with "Add comment" label', async () => { + await page.render(); + + const indicator = page.getByRole('button', { name: /add comment/i }); + + await expect.element(indicator).toBeInTheDocument(); + await expect.element(indicator).toHaveAttribute('aria-label', 'Add comment'); + }); + + it('should render default type with "View comment" label', async () => { + await page.render( + , + ); + + const indicator = page.getByRole('button', { name: /view comment/i }); + + await expect.element(indicator).toBeInTheDocument(); + await expect.element(indicator).toHaveAttribute('aria-label', 'View comment'); + }); + + it('should render action-required type with "View comment" label and actionRequired class', async () => { + await page.render( + , + ); + + const indicator = page.getByRole('button', { name: /view comment/i }); + + await expect.element(indicator).toBeInTheDocument(); + await expect.element(indicator).toHaveClass(/actionRequired/); + }); +}); diff --git a/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.module.scss b/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.module.scss index 51f626ad3..e550bd1f7 100644 --- a/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.module.scss +++ b/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.module.scss @@ -1,68 +1,3 @@ -.container { - display: flex; - flex-direction: column; - gap: var(--standard); -} - -.header { - display: grid; - grid-template-columns: 80px repeat(3, 100px); - gap: var(--standard); - padding-bottom: var(--xs); - border-bottom: 1px solid var(--border); - align-items: center; -} - -.headerCell { - display: flex; - align-items: center; - justify-content: center; - font-size: var(--body-font-size-sm); - font-weight: var(--font-weight-regular); - color: var(--font-main); - text-align: center; -} - -.row { - display: grid; - grid-template-columns: 80px repeat(3, 100px); - gap: var(--standard); - align-items: center; -} - -.labelCell { - display: flex; - align-items: center; - justify-content: flex-end; - padding-right: var(--xs); - font-size: var(--body-font-size-sm); - font-weight: var(--font-weight-regular); - color: var(--font-main); - text-align: right; -} - -.row > :not(.labelCell) { - display: flex; - justify-content: center; -} - -.interactiveContainer { - display: flex; - flex-direction: column; - gap: var(--standard); - padding: var(--standard); -} - -.instructions { - margin: 0; - padding: var(--standard); - background: var(--background-secondary); - border-radius: var(--border-radius-sm); - font-size: var(--body-font-size-sm); - line-height: var(--body-line-height-md); - color: var(--font-secondary); -} - .indicatorWrapper { position: relative; display: flex; diff --git a/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.tsx b/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.tsx index 38aac1652..71ec58d7d 100644 --- a/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.tsx +++ b/packages/design-system/src/components/ds-comment-indicator/ds-comment-indicator.stories.tsx @@ -1,8 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, fn, within } from 'storybook/test'; import { useState } from 'react'; +import { fn } from 'storybook/test'; import { DsCommentIndicator } from './index'; import { DsCommentBubble } from '../ds-comment-bubble'; +import { DsStack } from '../ds-stack'; +import { DsTypography } from '../ds-typography'; import type { CommentData, CommentAuthor } from '../ds-comment-card'; import styles from './ds-comment-indicator.stories.module.scss'; @@ -16,16 +18,12 @@ const meta: Meta = { type: { control: 'select', options: ['placeholder', 'default', 'action-required'], - description: 'Type of indicator', - }, - avatarSrc: { - control: 'text', - description: 'Avatar image URL for default/action-required types', - }, - onClick: { - action: 'clicked', - description: 'Click handler', }, + className: { table: { disable: true } }, + style: { table: { disable: true } }, + }, + args: { + onClick: fn(), }, }; @@ -33,90 +31,71 @@ export default meta; type Story = StoryObj; /** - * Placeholder indicator shows a "+" icon for adding new comments. - * This appears when hovering over entities that can have comments. + * Filled pin showing the avatar of an existing comment's author. Use when an entity + * already has a comment thread and no action is pending. */ -export const Placeholder: Story = { +export const Default: Story = { args: { - type: 'placeholder', - onClick: fn(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const indicator = canvas.getByRole('button', { name: /add comment/i }); - - await expect(indicator).toBeInTheDocument(); - await expect(indicator).toHaveAttribute('aria-label', 'Add comment'); + type: 'default', + avatarSrc: 'https://i.pravatar.cc/40?img=1', }, }; /** - * Default indicator shows the avatar of the comment initiator. - * Used when there are comments but no action is required. + * Dashed "+" affordance inviting the user to start a new comment. Typically revealed + * on hover over an entity that supports comments. */ -export const DefaultIndicator: Story = { +export const Placeholder: Story = { args: { - type: 'default', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - onClick: fn(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const indicator = canvas.getByRole('button', { name: /view comment/i }); - - await expect(indicator).toBeInTheDocument(); - await expect(indicator).toHaveAttribute('aria-label', 'View comment'); + type: 'placeholder', }, }; /** - * Action required indicator shows the avatar with an orange/red background. - * Used when a comment requires user action or response. + * Emphasized pin for comments flagged as requiring action, so they stand out from + * regular threads. */ export const ActionRequired: Story = { args: { type: 'action-required', avatarSrc: 'https://i.pravatar.cc/40?img=2', - onClick: fn(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const indicator = canvas.getByRole('button', { name: /view comment/i }); - - await expect(indicator).toBeInTheDocument(); - await expect(indicator).toHaveClass(/actionRequired/); }, }; -export const Default: Story = { +/** + * All indicator types side by side for visual comparison. + */ +export const AllTypes: Story = { + tags: ['!manifest'], + parameters: { + docs: { canvas: { sourceState: 'none' } }, + }, render: () => ( -
-
-
Placeholder
-
No action required
-
Action Required
-
-
+ + + + Placeholder + + + + + Default + + + + + Action required + -
-
+ + ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const indicators = canvas.getAllByRole('button'); - - await expect(indicators).toHaveLength(3); - - await expect(indicators[0]).toHaveAttribute('aria-label', 'Add comment'); - await expect(indicators[1]).toHaveAttribute('aria-label', 'View comment'); - await expect(indicators[2]).toHaveAttribute('aria-label', 'View comment'); - }, }; const currentUser: CommentAuthor = { @@ -157,11 +136,14 @@ const createMockComment = (): CommentData => ({ }); /** - * Interactive story showing a placeholder indicator that opens an empty comment bubble - * when clicked. Demonstrates the complete flow from empty bubble to thread with multiple messages. - * Click the "+" icon to add a new comment, then add replies to see the full thread. + * Integration demo: a placeholder indicator opens an empty bubble on click, then the + * bubble transitions from typing to a live thread as messages are sent. */ export const WithEmptyBubble: Story = { + tags: ['!manifest'], + parameters: { + docs: { canvas: { sourceState: 'none' } }, + }, render: function WithEmptyBubbleStory() { const [isOpen, setIsOpen] = useState(false); const [value, setValue] = useState(''); @@ -169,99 +151,45 @@ export const WithEmptyBubble: Story = { const [comment, setComment] = useState(undefined); const handleSend = (content: string, isActionRequired: boolean) => { - if (!comment) { - const newComment: CommentData = { - id: 'comment-1', - numericId: 42, - author: currentUser, - createdAt: new Date(), - isResolved: false, - messages: [ - { - id: 'msg-1', - author: currentUser, - content, - createdAt: new Date(), - isInitialMessage: true, - }, - ], - }; - setComment(newComment); - setActionRequired(isActionRequired); - } else { - const newMessage = { + setComment((prev) => { + const message = { id: `msg-${String(Date.now())}`, author: currentUser, content, createdAt: new Date(), }; - setComment((prev) => { - if (!prev) { - return prev; - } + if (!prev) { + setActionRequired(isActionRequired); return { - ...prev, - messages: [...prev.messages, newMessage], + id: 'comment-1', + numericId: 42, + author: currentUser, + createdAt: new Date(), + isResolved: false, + messages: [{ ...message, isInitialMessage: true }], }; - }); - } - setValue(''); - }; - - const handleEditMessage = (messageId: string, newContent: string) => { - if (!comment) { - return; - } - setComment((prev) => { - if (!prev) { - return prev; } - return { - ...prev, - messages: prev.messages.map((msg) => - msg.id === messageId ? { ...msg, content: newContent } : msg, - ), - }; - }); - }; - const handleDeleteMessage = (messageId: string) => { - if (!comment) { - return; - } - setComment((prev) => { - if (!prev) { - return prev; - } - return { - ...prev, - messages: prev.messages.filter((msg) => msg.id !== messageId), - }; + return { ...prev, messages: [...prev.messages, message] }; }); + setValue(''); }; const handleClose = () => { setComment(undefined); setValue(''); setActionRequired(false); + setIsOpen(false); }; return ( -
-

- Click the + icon to open an empty comment bubble, then follow the complete flow: -
- 1. Type a message → Typing mode appears -
- 2. Send → Creates thread with your first comment -
- 3. Add replies → Thread grows with multiple messages -

- + + + {'Click the "+" indicator to open an empty bubble, then send a message to create a thread.'} +
- setIsOpen(!isOpen)} /> - + setIsOpen((open) => !open)} /> {isOpen && (
console.log('Mark unread:', id)} - onMessageResolved={(id) => console.log('Resolved:', id)} - onClose={comment ? handleClose : () => setIsOpen(false)} - onResolve={() => console.log('Resolve clicked')} - onToggleActionRequired={() => console.log('Toggle action required')} - onForward={() => console.log('Forward')} - onMarkUnread={() => console.log('Mark unread')} - onCopyLink={() => console.log('Copy link')} - onDelete={() => console.log('Delete')} + onClose={handleClose} + onResolve={fn()} + onForward={fn()} + onMarkUnread={fn()} + onCopyLink={fn()} + onDelete={fn()} />
)}
-
+ ); }, }; /** - * Interactive story showing an indicator with an avatar that opens a comment bubble - * with existing comments when clicked. The bubble displays a full comment thread. + * Integration demo: a default indicator opens a bubble with an existing thread on + * click. Replies append to the thread live. */ export const WithExistingComments: Story = { + tags: ['!manifest'], + parameters: { + docs: { canvas: { sourceState: 'none' } }, + }, render: function WithExistingCommentsStory() { const [isOpen, setIsOpen] = useState(false); const [value, setValue] = useState(''); @@ -305,117 +232,27 @@ export const WithExistingComments: Story = { const [comment, setComment] = useState(createMockComment()); const handleSend = (content: string) => { - const newMessage = { - id: `msg-${String(Date.now())}`, - author: currentUser, - content, - createdAt: new Date(), - }; - setComment((prev) => ({ ...prev, - messages: [...prev.messages, newMessage], + messages: [ + ...prev.messages, + { id: `msg-${String(Date.now())}`, author: currentUser, content, createdAt: new Date() }, + ], })); setValue(''); }; - const handleEditMessage = (messageId: string, newContent: string) => { - setComment((prev) => ({ - ...prev, - messages: prev.messages.map((msg) => (msg.id === messageId ? { ...msg, content: newContent } : msg)), - })); - }; - - const handleDeleteMessage = (messageId: string) => { - setComment((prev) => ({ - ...prev, - messages: prev.messages.filter((msg) => msg.id !== messageId), - })); - }; - return ( -
-

- Click the avatar to view existing comments and add replies -

- + + + Click the avatar indicator to view the existing thread and add replies. +
setIsOpen(!isOpen)} - /> - - {isOpen && ( -
- console.log('Mark unread:', id)} - onMessageResolved={(id) => console.log('Resolved:', id)} - onClose={() => setIsOpen(false)} - onResolve={() => console.log('Resolve clicked')} - onToggleActionRequired={() => console.log('Toggle action required')} - onForward={() => console.log('Forward')} - onMarkUnread={() => console.log('Mark unread')} - onCopyLink={() => console.log('Copy link')} - onDelete={() => console.log('Delete')} - /> -
- )} -
-
- ); - }, -}; - -/** - * Interactive story showing an action-required indicator that opens a comment bubble - * with existing comments marked as requiring action. - */ -export const WithActionRequired: Story = { - render: function WithActionRequiredStory() { - const [isOpen, setIsOpen] = useState(false); - const [value, setValue] = useState(''); - const [actionRequired, setActionRequired] = useState(true); - const [comment, setComment] = useState(createMockComment()); - - const handleSend = (content: string) => { - const newMessage = { - id: `msg-${String(Date.now())}`, - author: currentUser, - content, - createdAt: new Date(), - }; - - setComment((prev) => ({ - ...prev, - messages: [...prev.messages, newMessage], - })); - setValue(''); - }; - - return ( -
-

- Click the action required avatar to view urgent comments -

- -
- setIsOpen(!isOpen)} + onClick={() => setIsOpen((open) => !open)} /> - {isOpen && (
setIsOpen(false)} - onResolve={() => console.log('Resolve clicked')} - onToggleActionRequired={() => console.log('Toggle action required')} - onForward={() => console.log('Forward')} - onMarkUnread={() => console.log('Mark unread')} - onCopyLink={() => console.log('Copy link')} - onDelete={() => console.log('Delete')} + onResolve={fn()} + onForward={fn()} + onMarkUnread={fn()} + onCopyLink={fn()} + onDelete={fn()} />
)}
-
+ ); }, }; diff --git a/packages/design-system/src/components/ds-comments-drawer/__tests__/__snapshots__/ds-comments-drawer.docs.snap b/packages/design-system/src/components/ds-comments-drawer/__tests__/__snapshots__/ds-comments-drawer.docs.snap new file mode 100644 index 000000000..ec5aa53b0 --- /dev/null +++ b/packages/design-system/src/components/ds-comments-drawer/__tests__/__snapshots__/ds-comments-drawer.docs.snap @@ -0,0 +1,163 @@ +# DsCommentsDrawer docs snippets + +## Default + +### Show code + {}} + onOpenChange={() => {}} + onResolveComment={() => {}} + onSearchChange={() => {}} + onShowResolvedChange={() => {}} + open +/> + +### MCP manifest +const Default = () => ; + +## Empty + +### Show code + {}} + onOpenChange={() => {}} + onResolveComment={() => {}} + onSearchChange={() => {}} + onShowResolvedChange={() => {}} + open +/> + +### MCP manifest +const Empty = () => ; + +## Custom Empty Message + +### Show code + {}} + onOpenChange={() => {}} + onResolveComment={() => {}} + onSearchChange={() => {}} + onShowResolvedChange={() => {}} + open +/> + +### MCP manifest +const CustomEmptyMessage = () => ; \ No newline at end of file diff --git a/packages/design-system/src/components/ds-comments-drawer/__tests__/ds-comments-drawer.browser.test.tsx b/packages/design-system/src/components/ds-comments-drawer/__tests__/ds-comments-drawer.browser.test.tsx new file mode 100644 index 000000000..1581f469a --- /dev/null +++ b/packages/design-system/src/components/ds-comments-drawer/__tests__/ds-comments-drawer.browser.test.tsx @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { DsCommentsDrawer } from '../index'; +import type { CommentData } from '../../ds-comment-card'; + +const createMockComments = (): CommentData[] => [ + { + id: 'comment-1', + numericId: 63, + author: { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/40?img=1', + }, + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + isResolved: false, + labels: ['Bug', 'High Priority'], + messages: [ + { + id: 'msg-1', + author: { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/40?img=1', + }, + content: + 'We need to review the resource allocation for this project. I think we should consider adjusting the timeline to ensure we have enough resources for the development phase.', + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + isInitialMessage: true, + }, + { + id: 'msg-2', + author: { + id: 'user-2', + name: 'John D.', + avatarSrc: 'https://i.pravatar.cc/40?img=2', + }, + content: 'Thanks for the feedback!', + createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), + }, + { + id: 'msg-3', + author: { + id: 'user-3', + name: 'Jane S.', + avatarSrc: 'https://i.pravatar.cc/40?img=3', + }, + content: 'I agree with this approach.', + createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000), + }, + ], + }, + { + id: 'comment-2', + numericId: 64, + author: { + id: 'user-2', + name: 'John D.', + avatarSrc: 'https://i.pravatar.cc/40?img=2', + }, + createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000), + isResolved: false, + labels: ['Feature Request'], + messages: [ + { + id: 'msg-4', + author: { + id: 'user-2', + name: 'John D.', + avatarSrc: 'https://i.pravatar.cc/40?img=2', + }, + content: + 'Could we add a dark mode feature to the application? This would improve usability for users working in low-light environments.', + createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000), + isInitialMessage: true, + }, + ], + }, + { + id: 'comment-3', + numericId: 65, + author: { + id: 'user-3', + name: 'Jane S.', + avatarSrc: 'https://i.pravatar.cc/40?img=3', + }, + createdAt: new Date(Date.now() - 72 * 60 * 60 * 1000), + isResolved: false, + labels: ['Documentation', 'Enhancement'], + messages: [ + { + id: 'msg-5', + author: { + id: 'user-3', + name: 'Jane S.', + avatarSrc: 'https://i.pravatar.cc/40?img=3', + }, + content: + 'The API documentation needs to be updated to reflect the recent changes we made to the authentication system. This will help developers integrate with our service more easily.', + createdAt: new Date(Date.now() - 72 * 60 * 60 * 1000), + isInitialMessage: true, + }, + { + id: 'msg-6', + author: { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/40?img=1', + }, + content: 'Good point!', + createdAt: new Date(Date.now() - 60 * 60 * 60 * 1000), + }, + { + id: 'msg-7', + author: { + id: 'user-4', + name: 'Bob M.', + avatarSrc: 'https://i.pravatar.cc/40?img=4', + }, + content: 'I will look into this.', + createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000), + }, + { + id: 'msg-8', + author: { + id: 'user-3', + name: 'Jane S.', + avatarSrc: 'https://i.pravatar.cc/40?img=3', + }, + content: 'Thanks everyone!', + createdAt: new Date(Date.now() - 36 * 60 * 60 * 1000), + }, + ], + }, + { + id: 'comment-4', + numericId: 66, + author: { + id: 'user-4', + name: 'Bob M.', + avatarSrc: 'https://i.pravatar.cc/40?img=4', + }, + createdAt: new Date(Date.now() - 96 * 60 * 60 * 1000), + isResolved: false, + labels: ['Question'], + messages: [ + { + id: 'msg-9', + author: { + id: 'user-4', + name: 'Bob M.', + avatarSrc: 'https://i.pravatar.cc/40?img=4', + }, + content: + 'Should we consider migrating to the new version of the framework? It offers better performance and security features that could benefit our application.', + createdAt: new Date(Date.now() - 96 * 60 * 60 * 1000), + isInitialMessage: true, + }, + ], + }, + { + id: 'comment-5', + numericId: 67, + author: { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/40?img=1', + }, + createdAt: new Date(Date.now() - 120 * 60 * 60 * 1000), + isResolved: true, + labels: ['Bug'], + messages: [ + { + id: 'msg-10', + author: { + id: 'user-1', + name: 'Karen J.', + avatarSrc: 'https://i.pravatar.cc/40?img=1', + }, + content: 'This has been resolved.', + createdAt: new Date(Date.now() - 120 * 60 * 60 * 1000), + isInitialMessage: true, + }, + ], + }, + { + id: 'comment-6', + numericId: 68, + author: { + id: 'user-2', + name: 'John D.', + avatarSrc: 'https://i.pravatar.cc/40?img=2', + }, + createdAt: new Date(Date.now() - 144 * 60 * 60 * 1000), + isResolved: true, + labels: ['Documentation'], + messages: [ + { + id: 'msg-11', + author: { + id: 'user-2', + name: 'John D.', + avatarSrc: 'https://i.pravatar.cc/40?img=2', + }, + content: 'Documentation updated.', + createdAt: new Date(Date.now() - 144 * 60 * 60 * 1000), + isInitialMessage: true, + }, + ], + }, +]; + +describe('DsCommentsDrawer', () => { + it('should show comment count in header for unresolved comments', async () => { + await page.render(); + + await expect.element(page.getByText(/4 Comments/i)).toBeInTheDocument(); + }); + + it('should render a search input', async () => { + await page.render( + , + ); + + await expect.element(page.getByPlaceholder(/search/i)).toBeInTheDocument(); + }); + + it('should show empty state when there are no comments', async () => { + await page.render(); + + await expect.element(page.getByText(/no comments yet/i)).toBeInTheDocument(); + }); + + it('should show resolved toggle with resolved count and call onShowResolvedChange with true', async () => { + const onShowResolvedChange = vi.fn(); + + await page.render( + , + ); + + const toggleButton = page.getByRole('button', { name: /show resolved/i }); + + await expect.element(toggleButton).toBeInTheDocument(); + await expect.element(toggleButton).toHaveTextContent(/\(2\)/); + + await toggleButton.click(); + + expect(onShowResolvedChange).toHaveBeenCalledWith(true); + }); + + it('should show hide resolved toggle and call onShowResolvedChange with false', async () => { + const onShowResolvedChange = vi.fn(); + + await page.render( + , + ); + + const toggleButton = page.getByRole('button', { name: /hide resolved/i }); + + await expect.element(toggleButton).toBeInTheDocument(); + + await toggleButton.click(); + + expect(onShowResolvedChange).toHaveBeenCalledWith(false); + }); + + it('should call onCommentClick when a comment card is clicked', async () => { + const onCommentClick = vi.fn(); + + await page.render( + , + ); + + const cards = page.getByRole('button', { name: /comment #/i }); + + await cards.first().click(); + + expect(onCommentClick).toHaveBeenCalledOnce(); + }); + + it('should filter comments by search text', async () => { + await page.render( + , + ); + + await expect.element(page.getByText(/dark mode/i)).toBeInTheDocument(); + expect(page.getByRole('button', { name: /comment #/i }).elements()).toHaveLength(1); + }); + + it('should filter comments by id search', async () => { + await page.render( + , + ); + + expect(page.getByRole('button', { name: /comment #/i }).elements()).toHaveLength(1); + }); + + it('should render a custom empty message', async () => { + await page.render( + , + ); + + await expect.element(page.getByText('Nothing to see here!')).toBeInTheDocument(); + }); + + it('should not show resolved toggle when there are no resolved comments', async () => { + const unresolvedComments: CommentData[] = createMockComments().filter((comment) => !comment.isResolved); + + await page.render(); + + await expect.element(page.getByRole('button', { name: /show resolved/i })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/ds-comments-drawer/ds-comments-drawer.stories.module.scss b/packages/design-system/src/components/ds-comments-drawer/ds-comments-drawer.stories.module.scss deleted file mode 100644 index 7e8ff89b1..000000000 --- a/packages/design-system/src/components/ds-comments-drawer/ds-comments-drawer.stories.module.scss +++ /dev/null @@ -1,26 +0,0 @@ -.storyWrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - padding: var(--standard); -} - -.filterDemo { - padding: var(--standard); - background-color: var(--background-secondary); -} - -.filterDemoTitle { - margin-bottom: var(--sm); -} - -.filterDemoText { - margin-bottom: var(--xs); - color: var(--font-secondary); -} - -.filterDemoList { - margin-left: var(--standard); - color: var(--font-secondary); -} diff --git a/packages/design-system/src/components/ds-comments-drawer/ds-comments-drawer.stories.tsx b/packages/design-system/src/components/ds-comments-drawer/ds-comments-drawer.stories.tsx index 010c5d038..3501902ac 100644 --- a/packages/design-system/src/components/ds-comments-drawer/ds-comments-drawer.stories.tsx +++ b/packages/design-system/src/components/ds-comments-drawer/ds-comments-drawer.stories.tsx @@ -1,80 +1,52 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, fn, userEvent, within } from 'storybook/test'; import { useState } from 'react'; +import { fn } from 'storybook/test'; import { DsCommentsDrawer } from './index'; -import { DsButton } from '../ds-button'; +import { DsButtonV3 } from '../ds-button-v3'; import type { CommentData } from '../ds-comment-card'; -import styles from './ds-comments-drawer.stories.module.scss'; -const createMockComments = (): CommentData[] => [ +const karen = { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }; +const john = { id: 'user-2', name: 'John D.', avatarSrc: 'https://i.pravatar.cc/40?img=2' }; +const jane = { id: 'user-3', name: 'Jane S.', avatarSrc: 'https://i.pravatar.cc/40?img=3' }; + +// Fixed timestamps keep the serialized docs snippets deterministic. +const createSampleComments = (): CommentData[] => [ { id: 'comment-1', numericId: 63, - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + author: karen, + createdAt: new Date('2026-02-08T10:00:00Z'), isResolved: false, labels: ['Bug', 'High Priority'], messages: [ { id: 'msg-1', - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - content: - 'We need to review the resource allocation for this project. I think we should consider adjusting the timeline to ensure we have enough resources for the development phase.', - createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + author: karen, + content: 'We need to review the resource allocation for this project before the next sprint.', + createdAt: new Date('2026-02-08T10:00:00Z'), isInitialMessage: true, }, { id: 'msg-2', - author: { - id: 'user-2', - name: 'John D.', - avatarSrc: 'https://i.pravatar.cc/40?img=2', - }, + author: john, content: 'Thanks for the feedback!', - createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), - }, - { - id: 'msg-3', - author: { - id: 'user-3', - name: 'Jane S.', - avatarSrc: 'https://i.pravatar.cc/40?img=3', - }, - content: 'I agree with this approach.', - createdAt: new Date(Date.now() - 6 * 60 * 60 * 1000), + createdAt: new Date('2026-02-08T12:00:00Z'), }, ], }, { id: 'comment-2', numericId: 64, - author: { - id: 'user-2', - name: 'John D.', - avatarSrc: 'https://i.pravatar.cc/40?img=2', - }, - createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000), + author: john, + createdAt: new Date('2026-02-07T10:00:00Z'), isResolved: false, labels: ['Feature Request'], messages: [ { - id: 'msg-4', - author: { - id: 'user-2', - name: 'John D.', - avatarSrc: 'https://i.pravatar.cc/40?img=2', - }, - content: - 'Could we add a dark mode feature to the application? This would improve usability for users working in low-light environments.', - createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000), + id: 'msg-3', + author: john, + content: 'Could we add a dark mode feature to improve usability in low-light environments?', + createdAt: new Date('2026-02-07T10:00:00Z'), isInitialMessage: true, }, ], @@ -82,131 +54,16 @@ const createMockComments = (): CommentData[] => [ { id: 'comment-3', numericId: 65, - author: { - id: 'user-3', - name: 'Jane S.', - avatarSrc: 'https://i.pravatar.cc/40?img=3', - }, - createdAt: new Date(Date.now() - 72 * 60 * 60 * 1000), - isResolved: false, - labels: ['Documentation', 'Enhancement'], - messages: [ - { - id: 'msg-5', - author: { - id: 'user-3', - name: 'Jane S.', - avatarSrc: 'https://i.pravatar.cc/40?img=3', - }, - content: - 'The API documentation needs to be updated to reflect the recent changes we made to the authentication system. This will help developers integrate with our service more easily.', - createdAt: new Date(Date.now() - 72 * 60 * 60 * 1000), - isInitialMessage: true, - }, - { - id: 'msg-6', - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - content: 'Good point!', - createdAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - }, - { - id: 'msg-7', - author: { - id: 'user-4', - name: 'Bob M.', - avatarSrc: 'https://i.pravatar.cc/40?img=4', - }, - content: 'I will look into this.', - createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000), - }, - { - id: 'msg-8', - author: { - id: 'user-3', - name: 'Jane S.', - avatarSrc: 'https://i.pravatar.cc/40?img=3', - }, - content: 'Thanks everyone!', - createdAt: new Date(Date.now() - 36 * 60 * 60 * 1000), - }, - ], - }, - { - id: 'comment-4', - numericId: 66, - author: { - id: 'user-4', - name: 'Bob M.', - avatarSrc: 'https://i.pravatar.cc/40?img=4', - }, - createdAt: new Date(Date.now() - 96 * 60 * 60 * 1000), - isResolved: false, - labels: ['Question'], - messages: [ - { - id: 'msg-9', - author: { - id: 'user-4', - name: 'Bob M.', - avatarSrc: 'https://i.pravatar.cc/40?img=4', - }, - content: - 'Should we consider migrating to the new version of the framework? It offers better performance and security features that could benefit our application.', - createdAt: new Date(Date.now() - 96 * 60 * 60 * 1000), - isInitialMessage: true, - }, - ], - }, - { - id: 'comment-5', - numericId: 67, - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - createdAt: new Date(Date.now() - 120 * 60 * 60 * 1000), - isResolved: true, - labels: ['Bug'], - messages: [ - { - id: 'msg-10', - author: { - id: 'user-1', - name: 'Karen J.', - avatarSrc: 'https://i.pravatar.cc/40?img=1', - }, - content: 'This has been resolved.', - createdAt: new Date(Date.now() - 120 * 60 * 60 * 1000), - isInitialMessage: true, - }, - ], - }, - { - id: 'comment-6', - numericId: 68, - author: { - id: 'user-2', - name: 'John D.', - avatarSrc: 'https://i.pravatar.cc/40?img=2', - }, - createdAt: new Date(Date.now() - 144 * 60 * 60 * 1000), + author: jane, + createdAt: new Date('2026-02-06T10:00:00Z'), isResolved: true, labels: ['Documentation'], messages: [ { - id: 'msg-11', - author: { - id: 'user-2', - name: 'John D.', - avatarSrc: 'https://i.pravatar.cc/40?img=2', - }, - content: 'Documentation updated.', - createdAt: new Date(Date.now() - 144 * 60 * 60 * 1000), + id: 'msg-4', + author: jane, + content: 'The API documentation has been updated to reflect the new authentication flow.', + createdAt: new Date('2026-02-06T10:00:00Z'), isInitialMessage: true, }, ], @@ -218,216 +75,116 @@ const meta: Meta = { component: DsCommentsDrawer, parameters: { layout: 'fullscreen', - docs: { - description: { - component: ` -Side panel for viewing and managing all comments. - -**Features:** -- Displays list of comment cards -- Search functionality -- Filter button (integration point) -- Show/hide resolved comments -- Comment count in header -- Click to open comment bubble -- Hover syncs with indicators on screen - `, - }, - }, }, argTypes: { - open: { - control: 'boolean', - description: 'Whether drawer is open', - }, - showResolved: { - control: 'boolean', - description: 'Whether to show resolved comments', - }, - searchQuery: { - control: 'text', - description: 'Search query', - }, + open: { control: 'boolean' }, + showResolved: { control: 'boolean' }, + searchQuery: { control: 'text' }, + comments: { table: { disable: true } }, + className: { table: { disable: true } }, + style: { table: { disable: true } }, }, args: { onOpenChange: fn(), - onShowResolvedChange: fn(), onSearchChange: fn(), + onShowResolvedChange: fn(), onCommentClick: fn(), onResolveComment: fn(), - onToggleActionRequired: fn(), - onForward: fn(), - onMarkUnread: fn(), - onCopyLink: fn(), - onDelete: fn(), }, }; export default meta; type Story = StoryObj; +/** + * Open drawer listing comment cards. The header shows the count of currently visible + * (unresolved) comments. Drive `searchQuery` and `showResolved` as controlled props. + */ export const Default: Story = { args: { open: true, - comments: createMockComments(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const title = canvas.getByText(/4 Comments/i); - - await expect(title).toBeInTheDocument(); - }, -}; - -export const WithSearch: Story = { - args: { - open: true, - comments: createMockComments(), - searchQuery: 'Karen', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const searchInput = canvas.getByPlaceholderText(/search/i); - - await expect(searchInput).toBeInTheDocument(); + comments: [ + { + id: 'comment-1', + numericId: 63, + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + createdAt: new Date('2026-02-08T10:00:00Z'), + isResolved: false, + labels: ['Bug', 'High Priority'], + messages: [ + { + id: 'msg-1', + author: { id: 'user-1', name: 'Karen J.', avatarSrc: 'https://i.pravatar.cc/40?img=1' }, + content: 'We need to review the resource allocation for this project before the next sprint.', + createdAt: new Date('2026-02-08T10:00:00Z'), + isInitialMessage: true, + }, + ], + }, + { + id: 'comment-2', + numericId: 64, + author: { id: 'user-2', name: 'John D.', avatarSrc: 'https://i.pravatar.cc/40?img=2' }, + createdAt: new Date('2026-02-07T10:00:00Z'), + isResolved: false, + labels: ['Feature Request'], + messages: [ + { + id: 'msg-2', + author: { id: 'user-2', name: 'John D.', avatarSrc: 'https://i.pravatar.cc/40?img=2' }, + content: 'Could we add a dark mode feature to improve usability in low-light environments?', + createdAt: new Date('2026-02-07T10:00:00Z'), + isInitialMessage: true, + }, + ], + }, + ], }, }; +/** + * Empty state shown when there are no comments to display. + */ export const Empty: Story = { args: { open: true, comments: [], }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const emptyMessage = canvas.getByText(/no comments yet/i); - - await expect(emptyMessage).toBeInTheDocument(); - }, -}; - -export const ShowResolvedToggle: Story = { - args: { - open: true, - comments: createMockComments(), - showResolved: false, - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const toggleButton = canvas.getByRole('button', { name: /show resolved/i }); - - await expect(toggleButton).toBeInTheDocument(); - await expect(toggleButton).toHaveTextContent(/\(2\)/); - - await userEvent.click(toggleButton); - - await expect(args.onShowResolvedChange).toHaveBeenCalledWith(true); - }, -}; - -export const HideResolvedToggle: Story = { - args: { - open: true, - comments: createMockComments(), - showResolved: true, - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const toggleButton = canvas.getByRole('button', { name: /hide resolved/i }); - - await expect(toggleButton).toBeInTheDocument(); - - await userEvent.click(toggleButton); - - await expect(args.onShowResolvedChange).toHaveBeenCalledWith(false); - }, -}; - -export const CommentCardClick: Story = { - args: { - open: true, - comments: createMockComments(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - const card = canvas.getAllByRole('button', { name: /comment #/i }).at(0); - - await expect(card).toBeDefined(); - await userEvent.click(card as HTMLElement); - - await expect(args.onCommentClick).toHaveBeenCalledOnce(); - }, -}; - -export const SearchFiltering: Story = { - args: { - open: true, - comments: createMockComments(), - searchQuery: 'dark mode', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const cards = canvas.getAllByRole('button', { name: /comment #/i }); - - await expect(cards).toHaveLength(1); - await expect(canvas.getByText(/dark mode/i)).toBeInTheDocument(); - }, -}; - -export const SearchById: Story = { - args: { - open: true, - comments: createMockComments(), - searchQuery: '#65', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const cards = canvas.getAllByRole('button', { name: /comment #/i }); - - await expect(cards).toHaveLength(1); - }, }; +/** + * Replace the built-in empty state with your own copy via `noCommentsMessage`. + */ export const CustomEmptyMessage: Story = { args: { open: true, comments: [], noCommentsMessage: 'Nothing to see here!', }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.getByText('Nothing to see here!')).toBeInTheDocument(); - }, -}; - -export const NoResolvedComments: Story = { - args: { - open: true, - comments: createMockComments().filter((c) => !c.isResolved), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(canvas.queryByRole('button', { name: /show resolved/i })).not.toBeInTheDocument(); - }, }; +/** + * Fully controlled drawer opened from a trigger button, with live search, resolve, + * and show-resolved state. + */ export const Interactive: Story = { + tags: ['!manifest'], + parameters: { + docs: { canvas: { sourceState: 'none' } }, + }, render: function InteractiveStory() { const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [showResolved, setShowResolved] = useState(false); - const [comments, setComments] = useState(createMockComments()); + const [comments, setComments] = useState(createSampleComments()); const handleResolve = (commentId: string) => { setComments((prev) => prev.map((c) => (c.id === commentId ? { ...c, isResolved: true } : c))); }; return ( -
- setOpen(true)}>Open Comments Drawer - + <> + setOpen(true)}>Open comments drawer console.log('Comment clicked:', comment.id)} + onCommentClick={fn()} onResolveComment={handleResolve} - onToggleActionRequired={(commentId) => console.log('Toggle action required:', commentId)} - onForward={(commentId) => console.log('Forward:', commentId)} - onMarkUnread={(commentId) => console.log('Mark unread:', commentId)} - onCopyLink={(commentId) => console.log('Copy link:', commentId)} - onDelete={(commentId) => console.log('Delete:', commentId)} - /> -
- ); - }, -}; - -export const WithFilters: Story = { - name: 'With Filters (Interactive)', - parameters: { - docs: { - description: { - story: ` -Interactive story demonstrating the filter functionality. - -**Try these interactions:** -1. Click the **Filter** button to open the filter modal -2. Select filters from different categories: - - **Status**: Filter by Unresolved, Resolved, or Action required - - **Author**: Filter by specific comment authors (Karen J., John D., Jane S., Bob M.) - - **Date range**: Filter by creation date - - **Labels**: Filter by tags (Bug, High Priority, Feature Request, Documentation, Enhancement, Question) -3. Click **Apply** to see the filtered results -4. Selected filters appear as chips below the toolbar -5. Click on a chip to remove that filter, or use **Clear all** to remove all filters - -**Current mock data:** -- 4 unresolved comments with various authors and labels -- 2 resolved comments -- Comment #63 has "Action required" status - `, - }, - }, - }, - render: function WithFiltersStory() { - const [open, setOpen] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [showResolved, setShowResolved] = useState(false); - const [comments] = useState(createMockComments()); - - return ( -
-
-

Filter Demonstration

-

- The drawer is open by default. Click the filter icon to explore filtering options. -

-

- Try filtering by: -

-
    -
  • Author: "Karen J." to see 2 comments
  • -
  • Label: "Bug" to see 2 comments
  • -
  • Status: "Action required" to see 1 comment
  • -
  • Multiple filters at once (e.g., Author + Label)
  • -
-
- - console.log('Comment clicked:', comment.id)} - onResolveComment={(commentId) => console.log('Resolve:', commentId)} - onToggleActionRequired={(commentId) => console.log('Toggle action required:', commentId)} - onForward={(commentId) => console.log('Forward:', commentId)} - onMarkUnread={(commentId) => console.log('Mark unread:', commentId)} - onCopyLink={(commentId) => console.log('Copy link:', commentId)} - onDelete={(commentId) => console.log('Delete:', commentId)} + onToggleActionRequired={fn()} + onForward={fn()} + onMarkUnread={fn()} + onCopyLink={fn()} + onDelete={fn()} /> -
+ ); }, }; diff --git a/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx b/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx index 6fbc191ba..c6f527bbe 100644 --- a/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx +++ b/packages/design-system/src/components/ds-main-menu/__tests__/ds-main-menu.browser.test.tsx @@ -278,9 +278,11 @@ describe('DsMainMenu — tile and utility link behavior', () => { const comingSoonTile = page.getByRole('button', { name: 'Coming soon app' }); const badge = comingSoonTile.element().querySelector('[class*="badge"]') as HTMLElement; - await userEvent.hover(badge); + await page.elementLocator(badge).hover(); - await expect.element(page.getByRole('tooltip', { name: COMING_SOON_TOOLTIP })).toBeVisible(); + await expect + .element(page.getByRole('tooltip', { name: COMING_SOON_TOOLTIP }), { timeout: 3000 }) + .toBeVisible(); }); it('renders an SVG component as the tile graphic', async () => { @@ -445,8 +447,10 @@ describe('DsMainMenu — expanded variant', () => { .element() .closest('li') ?.querySelector('[class*="expandedActionBadge"]') as HTMLElement; - await userEvent.hover(badge); - await expect.element(page.getByRole('tooltip', { name: COMING_SOON_TOOLTIP })).toBeVisible(); + await page.elementLocator(badge).hover(); + await expect + .element(page.getByRole('tooltip', { name: COMING_SOON_TOOLTIP }), { timeout: 3000 }) + .toBeVisible(); (comingSoonCard.element() as HTMLButtonElement).click(); diff --git a/packages/design-system/tests/storybook/docs-snippets.docs.test.ts b/packages/design-system/tests/storybook/docs-snippets.docs.test.ts index f03be6cc6..4ae704ded 100644 --- a/packages/design-system/tests/storybook/docs-snippets.docs.test.ts +++ b/packages/design-system/tests/storybook/docs-snippets.docs.test.ts @@ -20,6 +20,10 @@ const COMPONENTS = [ 'card', 'catalog-layout', 'checkbox', + 'comment-bubble', + 'comment-card', + 'comment-indicator', + 'comments-drawer', 'date-picker', 'date-range-picker', 'dialog',