From 0006b0d679d2cece925499a6d56246d7169b981c Mon Sep 17 00:00:00 2001 From: Jackie Jou Date: Mon, 29 Jun 2026 11:21:59 -0700 Subject: [PATCH] feat(activity-feed-v2): add TaskFormV2 with blueprint primitives and user-selector --- i18n/en-US.properties | 26 ++ .../task-modal-v2/TaskFormV2.scss | 11 + .../task-modal-v2/TaskFormV2.tsx | 195 ++++++++++++ .../task-modal-v2/TaskModalV2.scss | 18 +- .../__tests__/TaskFormV2.test.tsx | 296 ++++++++++++++++++ .../task-modal-v2/messages.ts | 65 ++++ 6 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.scss create mode 100644 src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.tsx create mode 100644 src/elements/content-sidebar/activity-feed-v2/task-modal-v2/__tests__/TaskFormV2.test.tsx diff --git a/i18n/en-US.properties b/i18n/en-US.properties index 3681645d7a..6fe49711d5 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -872,16 +872,42 @@ be.sort = Sort be.statusSkill = Status # Generic success label. be.success = Success +# Error message when the assignee field is empty on submit +be.taskModalV2.assigneeFieldRequiredError = Required Field +# Placeholder text for the assignee combobox in the task modal +be.taskModalV2.assigneePlaceholder = Add an assignee or group +# Label for the assignee combobox in the task modal +be.taskModalV2.assigneeSelectorLabel = Select Assignees # aria-label for the task modal close button be.taskModalV2.close = Close +# Label for the checkbox that switches a task to any-assignee completion +be.taskModalV2.completionRuleCheckboxLabel = Only one assignee is required to complete this task # Title of the modal for creating an approval task be.taskModalV2.createApprovalTask = Create Approval Task # Title of the modal for creating a general task be.taskModalV2.createGeneralTask = Create General Task +# aria-label for the calendar inside the due-date date picker +be.taskModalV2.datePickerCalendarAriaLabel = Due date calendar +# aria-label for the clear button on the due-date date picker +be.taskModalV2.datePickerClearAriaLabel = Clear due date +# aria-label for the next-month button in the due-date date picker +be.taskModalV2.datePickerNextMonthAriaLabel = Next month +# aria-label for the button that opens the due-date calendar +be.taskModalV2.datePickerOpenAriaLabel = Open due date calendar +# aria-label for the previous-month button in the due-date date picker +be.taskModalV2.datePickerPreviousMonthAriaLabel = Previous month +# Label for the due-date field in the task modal +be.taskModalV2.dueDateLabel = Due Date # Title of the modal for editing an existing approval task be.taskModalV2.editApprovalTask = Modify Approval Task # Title of the modal for editing an existing general task be.taskModalV2.editGeneralTask = Modify General Task +# Error message when the task message field is empty on submit +be.taskModalV2.messageFieldRequiredError = Required Field +# Label for the message field in the task modal +be.taskModalV2.messageLabel = Message +# Placeholder text for the message field in the task modal +be.taskModalV2.messagePlaceholder = Write a message # Shown instead of todays date. be.today = today # Label for keywords/topics skill section in the preview sidebar diff --git a/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.scss b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.scss new file mode 100644 index 0000000000..1db057cbfb --- /dev/null +++ b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.scss @@ -0,0 +1,11 @@ +.bcs-NewTaskForm { + display: flex; + flex-direction: column; + gap: var(--bp-space-040); + + // Allow direct children to shrink below their content's intrinsic width so + // the user-selector chips wrap inside the modal instead of widening it. + > * { + min-width: 0; + } +} diff --git a/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.tsx b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.tsx new file mode 100644 index 0000000000..b336a0dd71 --- /dev/null +++ b/src/elements/content-sidebar/activity-feed-v2/task-modal-v2/TaskFormV2.tsx @@ -0,0 +1,195 @@ +import * as React from 'react'; +import { useIntl } from 'react-intl'; + +import { Checkbox, DatePicker, TextArea } from '@box/blueprint-web'; +import { UserSelectorContainer } from '@box/user-selector'; +import type { FetchedAvatarUrls, UserContactType } from '@box/user-selector'; +import type { DateValue } from 'react-aria-components'; +import { fromDate, getLocalTimeZone, today } from '@internationalized/date'; + +import { TASK_COMPLETION_RULE_ALL, TASK_COMPLETION_RULE_ANY, TASK_EDIT_MODE_EDIT } from '../../../../constants'; + +import type { TaskCompletionRule, TaskEditMode, TaskType } from '../../../../common/types/tasks'; + +import { mapAssigneeToUserContact, mapUserContactToAssignee, type RuntimeAssignee } from './utils/contactMapping'; + +import messages from './messages'; + +import './TaskFormV2.scss'; + +export const TASK_FORM_V2_ID = 'task-form-v2'; + +export type TaskFormV2SubmitPayload = { + assignees: RuntimeAssignee[]; + completionRule: TaskCompletionRule; + dueDate: Date | null; + message: string; +}; + +export type TaskFormV2Props = { + editMode?: TaskEditMode; + fetchAvatarUrls: (contacts: UserContactType[]) => Promise; + fetchUsers: (query: string) => Promise; + initialAssignees?: RuntimeAssignee[]; + initialCompletionRule?: TaskCompletionRule; + initialDueDate?: Date | null; + initialMessage?: string; + isDisabled?: boolean; + onSubmit: (payload: TaskFormV2SubmitPayload) => void | Promise; + taskType: TaskType; +}; + +// Backend treats dueDate as inclusive end-of-day for new tasks; existing tasks +// keep their original time so edit-mode round-trips do not silently retime. +const toSubmitDate = ( + value: DateValue, + originalTime: { hours: number; minutes: number; seconds: number; ms: number } | null, +): Date => { + const date = value.toDate(getLocalTimeZone()); + if (originalTime) { + date.setHours(originalTime.hours, originalTime.minutes, originalTime.seconds, originalTime.ms); + } else { + date.setHours(23, 59, 59, 999); + } + return date; +}; + +const captureTime = (date: Date | null) => + date + ? { + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + ms: date.getMilliseconds(), + } + : null; + +const TaskFormV2 = ({ + editMode, + fetchAvatarUrls, + fetchUsers, + initialAssignees = [], + initialCompletionRule = TASK_COMPLETION_RULE_ALL, + initialDueDate = null, + initialMessage = '', + isDisabled = false, + onSubmit, + taskType, +}: TaskFormV2Props) => { + const { formatMessage } = useIntl(); + const isEditMode = editMode === TASK_EDIT_MODE_EDIT; + + const [formElement, setFormElement] = React.useState(null); + const portalElement = React.useCallback(() => formElement ?? document.body, [formElement]); + + const [selectedUsers, setSelectedUsers] = React.useState(() => + initialAssignees.map(mapAssigneeToUserContact), + ); + const [completionRule, setCompletionRule] = React.useState(initialCompletionRule); + const [message, setMessage] = React.useState(initialMessage); + const [dueDate, setDueDate] = React.useState( + initialDueDate ? fromDate(initialDueDate, getLocalTimeZone()) : null, + ); + const originalDueDateTime = React.useMemo( + () => (isEditMode ? captureTime(initialDueDate) : null), + [isEditMode, initialDueDate], + ); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = React.useState(false); + + const isGroupSelected = selectedUsers.some(user => user.type === 'group'); + const userAssigneeCount = selectedUsers.filter(user => user.type === 'user').length; + const shouldShowCompletionRule = selectedUsers.length > 0; + const isCompletionRuleDisabled = !isGroupSelected && userAssigneeCount <= 1; + + const messageError = + hasAttemptedSubmit && message.trim() === '' ? formatMessage(messages.messageFieldRequiredError) : undefined; + const assigneeError = + hasAttemptedSubmit && selectedUsers.length === 0 + ? formatMessage(messages.assigneeFieldRequiredError) + : undefined; + + const minCalendarDate = React.useMemo(() => { + const todayDate = today(getLocalTimeZone()); + if (initialDueDate && initialDueDate < todayDate.toDate(getLocalTimeZone())) { + return undefined; + } + return todayDate; + }, [initialDueDate]); + + const handleFormSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (isDisabled) { + return; + } + setHasAttemptedSubmit(true); + if (selectedUsers.length === 0 || message.trim() === '') { + return; + } + onSubmit({ + assignees: selectedUsers.map(mapUserContactToAssignee), + completionRule, + dueDate: dueDate ? toSubmitDate(dueDate, originalDueDateTime) : null, + message, + }); + }; + + return ( +
+ + {shouldShowCompletionRule && ( + + setCompletionRule(checked === true ? TASK_COMPLETION_RULE_ANY : TASK_COMPLETION_RULE_ALL) + } + value="any" + /> + )} +