UNPKG

box-ui-elements-mlh

Version:
494 lines (445 loc) 21.5 kB
/** * @flow * @file Component for Approval comment form */ import * as React from 'react'; import noop from 'lodash/noop'; import getProp from 'lodash/get'; import classNames from 'classnames'; import { FormattedMessage, injectIntl } from 'react-intl'; import type { InjectIntlProvidedProps } from 'react-intl'; import commonMessages from '../../../../common/messages'; import messages from './messages'; import commentFormMessages from '../comment-form/messages'; import Form from '../../../../components/form-elements/form/Form'; import ContactDatalistItem from '../../../../components/contact-datalist-item/ContactDatalistItem'; import TextArea from '../../../../components/text-area'; import DatePicker from '../../../../components/date-picker/DatePicker'; import Checkbox from '../../../../components/checkbox'; import PillSelectorDropdown from '../../../../components/pill-selector-dropdown/PillSelectorDropdown'; import Button from '../../../../components/button/Button'; import { FeatureFlag } from '../../../common/feature-checking'; import PrimaryButton from '../../../../components/primary-button/PrimaryButton'; import { TASK_COMPLETION_RULE_ANY, TASK_COMPLETION_RULE_ALL, TASK_EDIT_MODE_CREATE, TASK_EDIT_MODE_EDIT, } from '../../../../constants'; import { ACTIVITY_TARGETS, INTERACTION_TARGET } from '../../../common/interactionTargets'; import type { TaskCompletionRule, TaskCollabAssignee, TaskType, TaskEditMode, TaskUpdatePayload, } from '../../../../common/types/tasks'; import TaskError from './TaskError'; import type { GetAvatarUrlCallback } from '../../../common/flowTypes'; import type { ElementsXhrError } from '../../../../common/types/api'; import type { SelectorItems, SelectorItem, UserMini, GroupMini } from '../../../../common/types/core'; import './TaskForm.scss'; type TaskFormProps = {| error?: { status: number }, // TODO: update to ElementsXhrError once API supports it isDisabled?: boolean, onCancel: () => any, onSubmitError: (e: ElementsXhrError) => any, onSubmitSuccess: () => any, taskType: TaskType, |}; type TaskFormFieldProps = {| approvers: Array<TaskCollabAssignee>, completionRule: TaskCompletionRule, dueDate?: ?string, id: string, message: string, |}; type TaskFormConsumerProps = {| ...TaskFormFieldProps, approverSelectorContacts: SelectorItems<UserMini | GroupMini>, className?: string, createTask: ( text: string, approvers: SelectorItems<>, taskType: TaskType, dueDate: ?string, completionRule: TaskCompletionRule, onSuccess: ?Function, onError: ?Function, ) => any, editMode?: TaskEditMode, editTask?: (task: TaskUpdatePayload, onSuccess: ?Function, onError: ?Function) => any, getApproverWithQuery?: Function, getAvatarUrl: GetAvatarUrlCallback, |}; type Props = TaskFormProps & TaskFormConsumerProps & InjectIntlProvidedProps; type TaskFormFieldName = 'taskName' | 'taskAssignees' | 'taskDueDate'; type State = {| approverTextInput: string, // partial text input value for approver field before autocomplete/select approvers: Array<TaskCollabAssignee>, completionRule: TaskCompletionRule, dueDate?: ?Date, formValidityState: { [key: TaskFormFieldName]: ?{ code: string, message: string } }, id: string, isLoading: boolean, isValid: ?boolean, message: string, |}; function convertAssigneesToSelectorItems(approvers: Array<TaskCollabAssignee>): SelectorItems<> { return approvers.map(({ target }) => { const newSelectorItem: SelectorItem<UserMini | GroupMini> = { id: target.id, name: target.name, item: target, value: target.id, text: target.name, // for PillSelectorDropdown SelectorOptions type }; return newSelectorItem; }); } class TaskForm extends React.Component<Props, State> { static defaultProps = { approvers: [], approverSelectorContacts: [], editMode: TASK_EDIT_MODE_CREATE, id: '', message: '', }; state = this.getInitialFormState(); getInitialFormState() { const { dueDate, id, message, approvers, completionRule } = this.props; return { id, completionRule: completionRule || TASK_COMPLETION_RULE_ALL, approvers, approverTextInput: '', dueDate: dueDate ? new Date(dueDate) : null, formValidityState: {}, message, isLoading: false, isValid: null, }; } validateForm = (only?: TaskFormFieldName) => { this.setState(state => { const { intl } = this.props; const { approvers, message, approverTextInput } = state; const assigneeFieldMissingError = { code: 'required', message: intl.formatMessage(commonMessages.requiredFieldError), }; const assigneeFieldInvalidError = { code: 'invalid', message: intl.formatMessage(commonMessages.invalidUserError), }; const messageFieldError = { code: 'required', message: intl.formatMessage(commonMessages.requiredFieldError), }; const formValidityState = { taskAssignees: (approverTextInput.length ? assigneeFieldInvalidError : null) || (approvers.length ? null : assigneeFieldMissingError), taskName: message ? null : messageFieldError, taskDueDate: null, }; const isValid = Object.values(formValidityState).every(val => val == null); return { isValid, formValidityState: only ? { ...state.formValidityState, [only]: formValidityState[only] } : formValidityState, }; }); }; getErrorByFieldname = (fieldName: TaskFormFieldName) => { const { formValidityState } = this.state; return formValidityState[fieldName] ? formValidityState[fieldName].message : null; }; clearForm = () => this.setState(this.getInitialFormState()); handleInvalidSubmit = () => { this.validateForm(); }; handleSubmitSuccess = () => { const { onSubmitSuccess } = this.props; if (onSubmitSuccess) { onSubmitSuccess(); } this.clearForm(); this.setState({ isLoading: false }); }; handleSubmitError = (e: ElementsXhrError) => { const { onSubmitError } = this.props; onSubmitError(e); this.setState({ isLoading: false }); }; addResinInfo = (): Object => { const { id, taskType, editMode } = this.props; const { dueDate } = this.state; const addedAssignees = this.getAddedAssignees(); const removedAssignees = this.getRemovedAssignees(); return { 'data-resin-taskid': id, 'data-resin-tasktype': taskType, 'data-resin-isediting': editMode === TASK_EDIT_MODE_EDIT, 'data-resin-numassigneesadded': addedAssignees.filter(assignee => assignee.target.type === 'user').length, 'data-resin-numgroupssadded': addedAssignees.filter(assignee => assignee.target.type === 'group').length, 'data-resin-numassigneesremoved': removedAssignees.length, 'data-resin-assigneesadded': addedAssignees.map(assignee => assignee.target.id), 'data-resin-assigneesremoved': removedAssignees.map(assignee => assignee.target.id), 'data-resin-duedate': dueDate && dueDate.getTime(), }; }; getAddedAssignees = (): Array<TaskCollabAssignee> => { // Added assignees are the ones in state that weren't in the prop const { approvers } = this.props; const { approvers: currentApprovers } = this.state; const approverIds = approvers.map(approver => approver.id); return currentApprovers.filter(currentApprover => approverIds.indexOf(currentApprover.id) === -1); }; getRemovedAssignees = (): Array<TaskCollabAssignee> => { // Assignees to remove are the ones in the prop that cannot be found in state const { approvers } = this.props; const { approvers: currentApprovers } = this.state; const currentApproverIds = currentApprovers.map(currentApprover => currentApprover.id); return approvers.filter(approver => currentApproverIds.indexOf(approver.id) === -1); }; handleValidSubmit = (): void => { const { id, createTask, editTask, editMode, taskType } = this.props; const { message, approvers: currentApprovers, dueDate, completionRule, isValid } = this.state; const dueDateString = dueDate && dueDate.toISOString(); if (!isValid) return; this.setState({ isLoading: true }); if (editMode === TASK_EDIT_MODE_EDIT && editTask) { editTask( { id, completion_rule: completionRule, description: message, due_at: dueDateString, addedAssignees: convertAssigneesToSelectorItems(this.getAddedAssignees()), removedAssignees: this.getRemovedAssignees(), }, this.handleSubmitSuccess, this.handleSubmitError, ); } else { createTask( message, convertAssigneesToSelectorItems(currentApprovers), taskType, dueDateString, completionRule, this.handleSubmitSuccess, this.handleSubmitError, ); } }; handleDueDateChange = (date: ?string): void => { let dateValue = null; if (date) { dateValue = new Date(date); // The date given to us is midnight of the date selected. // Modify date to be the end of day (minus 1 millisecond) for the given due date dateValue.setHours(23, 59, 59, 999); } this.setState({ dueDate: dateValue }); this.validateForm('taskDueDate'); }; handleCompletionRuleChange = (event: SyntheticInputEvent<HTMLInputElement>) => { this.setState({ completionRule: event.target.checked ? TASK_COMPLETION_RULE_ANY : TASK_COMPLETION_RULE_ALL }); }; handleApproverSelectorInput = (value: any): void => { const { getApproverWithQuery = noop } = this.props; this.setState({ approverTextInput: value }); getApproverWithQuery(value); }; handleApproverSelectorSelect = (pills: Array<any>): void => { this.setState({ approvers: this.state.approvers.concat( pills.map(pill => { return { id: '', target: pill.item, role: 'ASSIGNEE', type: 'task_collaborator', status: 'NOT_STARTED', permissions: { can_delete: false, can_update: false }, }; }), ), approverTextInput: '', }); this.validateForm('taskAssignees'); }; handleApproverSelectorRemove = (option: any, index: number): void => { const approvers = [...this.state.approvers]; approvers.splice(index, 1); this.setState({ approvers }); this.validateForm('taskAssignees'); }; handleChangeMessage = (e: SyntheticInputEvent<HTMLTextAreaElement>) => { e.persist(); this.setState({ message: e.currentTarget.value }); this.validateForm('taskName'); }; handleCancelClick = () => { this.props.onCancel(); }; render() { const { approverSelectorContacts, className, error, isDisabled, intl, editMode, taskType } = this.props; const { dueDate, approvers, message, formValidityState, isLoading, completionRule } = this.state; const inputContainerClassNames = classNames('bcs-task-input-container', 'bcs-task-input-is-open', className); const isCreateEditMode = editMode === TASK_EDIT_MODE_CREATE; const selectedApprovers = convertAssigneesToSelectorItems(approvers); // filter out selected approvers // map to datalist item format const approverOptions = approverSelectorContacts.filter( ({ id }) => !selectedApprovers.find(({ value }) => value === id), ); const pillSelectorOverlayClasses = classNames({ scrollable: approverOptions.length > 4, }); const submitButtonMessage = isCreateEditMode ? messages.tasksAddTaskFormSubmitLabel : messages.tasksEditTaskFormSubmitLabel; const shouldShowCompletionRule = approvers.length > 0; // Enable checkbox when there is a group or multiple users being assigned // TODO: consider setting contants for assignee types to src/constants.js // - move from src/features/collaborator-avatars/constants.js const isCompletionRuleCheckboxDisabled = approvers.filter(approver => approver.target.type === 'group').length <= 0 && approvers.filter(approver => approver.target.type === 'user').length <= 1; const isCompletionRuleCheckboxChecked = completionRule === TASK_COMPLETION_RULE_ANY; const isForbiddenErrorOnEdit = isLoading || (getProp(error, 'status') === 403 && !isCreateEditMode); return ( <div className={inputContainerClassNames} data-resin-component="taskform"> <div className="bcs-task-input-form-container"> <TaskError editMode={editMode} error={error} taskType={taskType} /> <Form formValidityState={formValidityState} onInvalidSubmit={this.handleInvalidSubmit} onValidSubmit={this.handleValidSubmit} > <PillSelectorDropdown className={pillSelectorOverlayClasses} error={this.getErrorByFieldname('taskAssignees')} disabled={isForbiddenErrorOnEdit} inputProps={{ 'data-testid': 'task-form-assignee-input' }} isRequired label={<FormattedMessage {...messages.tasksAddTaskFormSelectAssigneesLabel} />} name="taskAssignees" onBlur={() => this.validateForm('taskAssignees')} onInput={this.handleApproverSelectorInput} onRemove={this.handleApproverSelectorRemove} onSelect={this.handleApproverSelectorSelect} placeholder={intl.formatMessage(commentFormMessages.approvalAddAssignee)} selectedOptions={selectedApprovers} selectorOptions={approverOptions} shouldSetActiveItemOnOpen shouldClearUnmatchedInput validateForError={() => this.validateForm('taskAssignees')} > {approverOptions.map(({ id, name, item = {} }) => ( <ContactDatalistItem key={id} data-testid="task-assignee-option" name={name} subtitle={ item.type === 'group' ? ( <FormattedMessage {...messages.taskCreateGroupLabel} /> ) : ( item.email ) } /> ))} </PillSelectorDropdown> {shouldShowCompletionRule && ( <> <FeatureFlag feature="activityFeed.tasks.assignToGroup"> <Checkbox data-testid="task-form-completion-rule-checkbox-group" isChecked={isCompletionRuleCheckboxChecked} isDisabled={isCompletionRuleCheckboxDisabled || isForbiddenErrorOnEdit} label={<FormattedMessage {...messages.taskAnyCheckboxLabel} />} tooltip={intl.formatMessage(messages.taskAnyInfoGroupTooltip)} name="completionRule" onChange={this.handleCompletionRuleChange} /> </FeatureFlag> <FeatureFlag not feature="activityFeed.tasks.assignToGroup"> <Checkbox data-testid="task-form-completion-rule-checkbox" isChecked={isCompletionRuleCheckboxChecked} isDisabled={isCompletionRuleCheckboxDisabled || isForbiddenErrorOnEdit} label={<FormattedMessage {...messages.taskAnyCheckboxLabel} />} tooltip={intl.formatMessage(messages.taskAnyInfoTooltip)} name="completionRule" onChange={this.handleCompletionRuleChange} /> </FeatureFlag> </> )} <TextArea className="bcs-task-name-input" data-testid="task-form-name-input" disabled={isDisabled || isForbiddenErrorOnEdit} error={this.getErrorByFieldname('taskName')} isRequired label={<FormattedMessage {...messages.tasksAddTaskFormMessageLabel} />} name="taskName" onBlur={() => this.validateForm('taskName')} onChange={this.handleChangeMessage} placeholder={intl.formatMessage(commentFormMessages.commentWrite)} value={message} /> <DatePicker className="bcs-task-add-due-date-input" error={this.getErrorByFieldname('taskDueDate')} inputProps={{ [INTERACTION_TARGET]: ACTIVITY_TARGETS.TASK_DATE_PICKER, 'data-testid': 'task-form-date-input', }} isDisabled={isForbiddenErrorOnEdit} isRequired={false} label={<FormattedMessage {...messages.tasksAddTaskFormDueDateLabel} />} minDate={new Date()} name="taskDueDate" onChange={this.handleDueDateChange} placeholder={intl.formatMessage(commentFormMessages.approvalSelectDate)} value={dueDate || undefined} /> <div className="bcs-task-input-controls"> <Button className="bcs-task-input-cancel-btn" data-resin-target={ACTIVITY_TARGETS.APPROVAL_FORM_CANCEL} data-testid="task-form-cancel-button" onClick={this.handleCancelClick} isDisabled={isLoading} type="button" {...this.addResinInfo()} > <FormattedMessage {...messages.tasksAddTaskFormCancelLabel} /> </Button> <PrimaryButton className="bcs-task-input-submit-btn" data-resin-target={ACTIVITY_TARGETS.APPROVAL_FORM_POST} data-testid="task-form-submit-button" isDisabled={isForbiddenErrorOnEdit} isLoading={isLoading} {...this.addResinInfo()} > <FormattedMessage {...submitButtonMessage} /> </PrimaryButton> </div> </Form> </div> </div> ); } } // For testing only export { TaskForm as TaskFormUnwrapped }; export type { TaskFormConsumerProps as TaskFormProps }; export default injectIntl(TaskForm);