diff --git a/src/elements/content-sidebar/stories/TaskModalV2.stories.tsx b/src/elements/content-sidebar/stories/TaskModalV2.stories.tsx new file mode 100644 index 0000000000..b5f361520b --- /dev/null +++ b/src/elements/content-sidebar/stories/TaskModalV2.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { + InteractiveTaskModal, + mockEditingAssignees, + mockEditingTask, + mockSubmitError, +} from './__mocks__/TaskModalV2Mocks'; + +import { TASK_TYPE_APPROVAL, TASK_TYPE_GENERAL } from '../../../constants'; + +const meta: Meta = { + title: 'Elements/ContentSidebar/TaskModalV2', + component: InteractiveTaskModal, + parameters: { + // Modal portals to document.body, so inline docs previews would stack every story's open dialog + docs: { story: { iframeHeight: 640, inline: false } }, + }, +}; + +export default meta; + +export const CreateGeneralTask: StoryObj = { + args: { + taskType: TASK_TYPE_GENERAL, + }, +}; + +export const CreateApprovalTask: StoryObj = { + args: { + taskType: TASK_TYPE_APPROVAL, + }, +}; + +export const EditApprovalTask: StoryObj = { + args: { + editingAssignees: mockEditingAssignees, + editingTask: mockEditingTask, + taskType: TASK_TYPE_APPROVAL, + }, +}; + +export const SubmitError: StoryObj = { + args: { + initialError: mockSubmitError, + shouldFailSubmit: true, + taskType: TASK_TYPE_APPROVAL, + }, +}; diff --git a/src/elements/content-sidebar/stories/__mocks__/TaskModalV2Mocks.tsx b/src/elements/content-sidebar/stories/__mocks__/TaskModalV2Mocks.tsx new file mode 100644 index 0000000000..16869c6c5b --- /dev/null +++ b/src/elements/content-sidebar/stories/__mocks__/TaskModalV2Mocks.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; + +import { BlueprintModernizationProvider, Button, TooltipProvider } from '@box/blueprint-web'; +import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector'; + +import TaskModalV2 from '../../activity-feed-v2/task-modal-v2'; + +import type { ElementsXhrError } from '../../../../common/types/api'; +import type { TaskNew, TaskType } from '../../../../common/types/tasks'; +import type { CreateTaskCallback, EditTaskCallback, TaskAssignee } from '../../activity-feed-v2/task-modal-v2/types'; + +import { TASK_COMPLETION_RULE_ALL, TASK_TYPE_APPROVAL } from '../../../../constants'; + +export const mockContacts: UserContactType[] = [ + { email: 'awong@example.com', id: 1, name: 'Alice Wong', type: 'user', value: '1' }, + { email: 'bsmith@example.com', id: 2, name: 'Bob Smith', type: 'user', value: '2' }, + { email: 'cnguyen@example.com', id: 3, name: 'Charlie Nguyen', type: 'user', value: '3' }, + { email: '', id: 100, name: 'Design Team', type: 'group', value: '100' }, + { email: '', id: 101, name: 'Engineering Team', type: 'group', value: '101' }, +]; + +export const mockFetchUsers = (query: string): Promise => + Promise.resolve(mockContacts.filter(contact => contact.name.toLowerCase().includes(query.toLowerCase()))); + +export const mockFetchAvatarUrls = (): Promise => Promise.resolve({}); + +const buildUserAssignee = (collaboratorId: string, id: string, name: string, email: string): TaskAssignee => ({ + id: collaboratorId, + permissions: { can_delete: false, can_update: false }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target: { email, id, name, type: 'user' }, + type: 'task_collaborator', +}); + +const buildGroupAssignee = (collaboratorId: string, id: string, name: string): TaskAssignee => ({ + id: collaboratorId, + permissions: { can_delete: false, can_update: false }, + role: 'ASSIGNEE', + status: 'NOT_STARTED', + target: { id, name, type: 'group' }, + type: 'task_collaborator', +}); + +export const mockSubmitError: ElementsXhrError = { status: 500 } as ElementsXhrError; + +export const mockEditingAssignees: TaskAssignee[] = [ + buildUserAssignee('collab-1', '1', 'Alice Wong', 'awong@example.com'), + buildGroupAssignee('collab-2', '101', 'Engineering Team'), +]; + +export const mockEditingTask: TaskNew = { + assigned_to: { entries: mockEditingAssignees, limit: 25, next_marker: '' }, + completion_rule: TASK_COMPLETION_RULE_ALL, + created_at: '2026-06-30T12:00:00Z', + created_by: { + id: 'creator', + role: 'CREATOR', + status: 'NOT_STARTED', + target: { id: 'creator', name: 'Creator', type: 'user' }, + type: 'task_collaborator', + }, + description: 'Review the updated launch checklist', + // Midday UTC so the local calendar date stays July 15 in any test timezone + due_at: '2026-07-15T12:00:00Z', + id: 'task-1', + modified_at: '2026-06-30T12:00:00Z', + permissions: { + can_create_task_collaborator: true, + can_create_task_link: true, + can_delete: true, + can_update: true, + }, + status: 'NOT_STARTED', + task_links: { entries: [], limit: 25, next_marker: '' }, + task_type: TASK_TYPE_APPROVAL, + type: 'task', +}; + +export type InteractiveTaskModalProps = { + editingAssignees?: TaskAssignee[]; + editingTask?: TaskNew; + initialError?: ElementsXhrError; + shouldFailSubmit?: boolean; + taskType: TaskType; +}; + +export const InteractiveTaskModal = ({ + editingAssignees = [], + editingTask, + initialError, + shouldFailSubmit = false, + taskType, +}: InteractiveTaskModalProps) => { + const [error, setError] = React.useState(initialError); + const [isOpen, setIsOpen] = React.useState(true); + + const handleClose = () => { + setError(undefined); + setIsOpen(false); + }; + + const finishSubmit = (onSuccess: () => void, onError: (submitError: ElementsXhrError) => void) => { + setTimeout(() => { + if (shouldFailSubmit) { + onError(mockSubmitError); + return; + } + onSuccess(); + }, 400); + }; + + const createTask: CreateTaskCallback = (text, approvers, type, dueDate, completionRule, onSuccess, onError) => + finishSubmit(onSuccess, onError); + + const editTask: EditTaskCallback = (payload, onSuccess, onError) => finishSubmit(onSuccess, onError); + + const sharedProps = { + createTask, + error, + fetchAvatarUrls: mockFetchAvatarUrls, + fetchUsers: mockFetchUsers, + isOpen, + onClose: handleClose, + onSubmitError: setError, + onSubmitSuccess: handleClose, + taskType, + }; + + return ( + + + + {editingTask ? ( + + ) : ( + + )} + + + ); +}; diff --git a/src/elements/content-sidebar/stories/tests/TaskModalV2-visual.stories.tsx b/src/elements/content-sidebar/stories/tests/TaskModalV2-visual.stories.tsx new file mode 100644 index 0000000000..5e7c3521fb --- /dev/null +++ b/src/elements/content-sidebar/stories/tests/TaskModalV2-visual.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, screen, userEvent } from 'storybook/test'; + +import { InteractiveTaskModal } from '../__mocks__/TaskModalV2Mocks'; + +import { TASK_TYPE_APPROVAL } from '../../../../constants'; + +const meta: Meta = { + title: 'Elements/ContentSidebar/TaskModalV2/tests/visual-regression-tests', + component: InteractiveTaskModal, + parameters: { + docs: { story: { iframeHeight: 640, inline: false } }, + }, +}; + +export default meta; + +const findAssigneeCombobox = async () => screen.findByRole('combobox', { name: 'Select Assignees' }); + +export const CreateApprovalTaskTabOrder: StoryObj = { + args: { + taskType: TASK_TYPE_APPROVAL, + }, + play: async () => { + await screen.findByRole('dialog', { name: 'Create Approval Task' }); + + const combobox = await findAssigneeCombobox(); + combobox.focus(); + expect(combobox).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByRole('textbox', { name: /message/i })).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByRole('spinbutton', { name: /month/i })).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByRole('spinbutton', { name: /day/i })).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByRole('spinbutton', { name: /year/i })).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByRole('button', { name: /open due date calendar/i })).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus(); + + await userEvent.tab(); + expect(screen.getByRole('button', { name: 'Create' })).toHaveFocus(); + }, +}; + +export const AssigneeListboxAccessibleNames: StoryObj = { + args: { + taskType: TASK_TYPE_APPROVAL, + }, + play: async () => { + await screen.findByRole('dialog', { name: 'Create Approval Task' }); + + const combobox = await findAssigneeCombobox(); + await userEvent.type(combobox, 'team'); + + const listbox = await screen.findByRole('listbox'); + expect(listbox).toBeVisible(); + + const designOption = await screen.findByRole('option', { name: /Design Team/ }); + const engineeringOption = await screen.findByRole('option', { name: /Engineering Team/ }); + expect(designOption).toBeVisible(); + expect(engineeringOption).toBeVisible(); + }, +};