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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -886,16 +886,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<FetchedAvatarUrls>;
fetchUsers: (query: string) => Promise<UserContactType[]>;
initialAssignees?: RuntimeAssignee[];
initialCompletionRule?: TaskCompletionRule;
initialDueDate?: Date | null;
initialMessage?: string;
isDisabled?: boolean;
onSubmit: (payload: TaskFormV2SubmitPayload) => void | Promise<void>;
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

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<HTMLFormElement | null>(null);
const portalElement = React.useCallback(() => formElement ?? document.body, [formElement]);

const [selectedUsers, setSelectedUsers] = React.useState<UserContactType[]>(() =>
initialAssignees.map(mapAssigneeToUserContact),
);
const [completionRule, setCompletionRule] = React.useState<TaskCompletionRule>(initialCompletionRule);
const [message, setMessage] = React.useState<string>(initialMessage);
const [dueDate, setDueDate] = React.useState<DateValue | null>(
initialDueDate ? fromDate(initialDueDate, getLocalTimeZone()) : null,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const originalDueDateTime = React.useMemo(
() => (isEditMode ? captureTime(initialDueDate) : null),
[isEditMode, initialDueDate],
);
const [hasAttemptedSubmit, setHasAttemptedSubmit] = React.useState<boolean>(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<HTMLFormElement>) => {
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,
});
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<form
ref={setFormElement}
className="bcs-NewTaskForm"
data-resin-component="taskformv2"
data-resin-isediting={isEditMode}
data-resin-tasktype={taskType}
id={TASK_FORM_V2_ID}
onSubmit={handleFormSubmit}
>
<UserSelectorContainer
disabled={isDisabled}
error={assigneeError}
fetchAvatarUrls={fetchAvatarUrls}
fetchUsers={fetchUsers}
label={formatMessage(messages.assigneeSelectorLabel)}
onSelectedUsersChange={setSelectedUsers}
placeholder={selectedUsers.length ? '' : formatMessage(messages.assigneePlaceholder)}
portalElement={portalElement}
selectedUsers={selectedUsers}
/>
{shouldShowCompletionRule && (
<Checkbox.Item
checked={completionRule === TASK_COMPLETION_RULE_ANY}
disabled={isDisabled || isCompletionRuleDisabled}
label={formatMessage(messages.completionRuleCheckboxLabel)}
name="completionRule"
onCheckedChange={checked =>
setCompletionRule(checked === true ? TASK_COMPLETION_RULE_ANY : TASK_COMPLETION_RULE_ALL)
}
value="any"
/>
)}
<TextArea
disabled={isDisabled}
error={messageError}
label={formatMessage(messages.messageLabel)}
minRows={3}
name="taskMessage"
onChange={event => setMessage(event.target.value)}
placeholder={formatMessage(messages.messagePlaceholder)}
value={message}
/>
<DatePicker
calendarAriaLabel={formatMessage(messages.datePickerCalendarAriaLabel)}
clearDatePickerAriaLabel={formatMessage(messages.datePickerClearAriaLabel)}
isDisabled={isDisabled}
label={formatMessage(messages.dueDateLabel)}
minValue={minCalendarDate}
nextMonthAriaLabel={formatMessage(messages.datePickerNextMonthAriaLabel)}
onChange={setDueDate}
openCalendarDropdownAriaLabel={formatMessage(messages.datePickerOpenAriaLabel)}
previousMonthAriaLabel={formatMessage(messages.datePickerPreviousMonthAriaLabel)}
value={dueDate}
/>
</form>
);
};

export default TaskFormV2;
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
.bcs-NewTaskModal {
// Doubled class raises specificity above Blueprint Modal.Content size="medium" max-width default.
.bcs-NewTaskModal.bcs-NewTaskModal {
display: flex;
flex-direction: column;
max-width: 480px;
gap: var(--bp-space-040);
}

// Override Blueprint Modal.Body padding-top via doubled-class specificity.
.bcs-NewTaskModal-body.bcs-NewTaskModal-body {
position: relative;
padding-top: 0;

// Pins the body to the modal's max-width so chip overflow inside the
// user-selector cannot widen Modal.Content past its 'medium' clamp.
&::before {
display: block;
width: 100vw;
content: '';
}
}
Loading
Loading