src/views/issue/activity/activity__add-spent-time.tsx (356 lines of code) (raw):
import React, {memo, useContext, useEffect, useState} from 'react';
import {ActivityIndicator, Text, TextInput, TouchableOpacity, View} from 'react-native';
import {useSelector} from 'react-redux';
import {useDispatch} from 'hooks/use-dispatch';
import DatePicker from 'components/date-picker/date-picker';
import Header from 'components/header/header';
import ModalView from 'components/modal-view/modal-view';
import Router from 'components/router/router';
import Select from 'components/select/select';
import usage from 'components/usage/usage';
import {absDate} from 'components/date/date';
import {ANALYTICS_ISSUE_STREAM_SECTION} from 'components/analytics/analytics-ids';
import {ColorBullet} from 'components/color-field/color-field';
import {confirmation} from 'components/confirmation/confirmation';
import {createIssueActivityActions} from './issue-activity__actions';
import {getEntityPresentation} from 'components/issue-formatter/issue-formatter';
import {hasType} from 'components/api/api__resource-types';
import {HIT_SLOP, HIT_SLOP2} from 'components/common-styles';
import {i18n} from 'components/i18n/i18n';
import {IconAngleRight, IconCheck, IconClose} from 'components/icon/icon';
import {logEvent} from 'components/log/log-helper';
import {sortByOrdinal} from 'components/search/sorting';
import {ThemeContext} from 'components/theme/theme-context';
import styles from './activity__add-spent-time.styles';
import type {AppState} from 'reducers';
import type {ISelectProps} from 'components/select/select';
import type {IssueFull} from 'types/Issue';
import type {Theme} from 'types/Theme';
import type {User} from 'types/User';
import type {ViewStyleProp} from 'types/Internal';
import type {
DraftWorkItem,
ProjectTimeTrackingSettings,
WorkItem,
WorkItemAttribute,
WorkItemAttributeValue,
WorkItemType,
} from 'types/Work';
interface Props {
issue: IssueFull;
workItem?: WorkItem;
onAdd: () => void;
onHide: () => void;
canCreateNotOwn: boolean;
}
type SelectPropsType = {ringId: string; name: string} | WorkItemType | WorkItemAttributeValue;
const AddSpentTimeForm = (props: Props) => {
const currentUser = useSelector((state: AppState) => state.app.user!);
const theme: Theme = useContext(ThemeContext);
const dispatch = useDispatch();
const issueActivityActions = createIssueActivityActions();
const [isProgress, updateProgress] = useState(false);
const [isSelectVisible, updateSelectVisibility] = useState(false);
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
const [draft, updateDraftWorkItem] = useState<WorkItem | DraftWorkItem>(
props.workItem || createDraftWorkItem(props.issue, currentUser)
);
const [selectProps, updateSelectProps] = useState<ISelectProps<SelectPropsType> | null>(null);
const [hasError, setHasError] = useState(false);
const [spentTime, setSpentTime] = useState(props.workItem?.duration.presentation || '');
const doHide = () => {
if (props.onHide) {
props.onHide();
} else {
Router.pop(true);
}
};
const getIssueId = (): string => ((props.issue || props.workItem?.issue) as IssueFull).id;
useEffect(() => {
if (props.workItem) {
updateDraftWorkItem(props.workItem);
} else {
createDraft();
}
async function createDraft() {
const issueId = getIssueId();
const timeTracking = await dispatch(issueActivityActions.getTimeTracking(issueId));
const draftData = {...draft, ...timeTracking?.draftWorkItem, type: null};
updateProgress(true);
const _draft = await dispatch(issueActivityActions.updateWorkItemDraft(draftData, issueId));
updateProgress(false);
if (_draft) {
updateDraftWorkItem({...draftData, ..._draft});
}
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.workItem, dispatch]);
const update = (data: Partial<WorkItem>) => {
setHasError(false);
updateDraftWorkItem({...draft, ...data});
};
const renderSelect = (p: ISelectProps<SelectPropsType>) => {
const defaultSelectProps: ISelectProps<SelectPropsType> = {
multi: false,
dataSource: () => Promise.resolve([]),
selectedItems: [],
getTitle: (it: Record<string, any>) => getEntityPresentation(it),
onCancel: () => updateSelectVisibility(false),
onChangeSelection: () => null,
onSelect: () => {
usage.trackEvent(ANALYTICS_ISSUE_STREAM_SECTION, 'SpentTime: visibility update');
updateSelectVisibility(false);
},
};
return <Select {...Object.assign({}, defaultSelectProps, p)} />;
};
const getUserSelectProps = (user: {ringId: string}): ISelectProps<SelectPropsType> => {
return {
selectedItems: [],
dataSource: async () => {
const project = props?.issue?.project || props.workItem?.issue?.project;
const users = await dispatch(issueActivityActions.getWorkItemAuthors(project.ringId));
return users.filter(u => u.ringId !== user.ringId);
},
onSelect: (author: User) => {
logEvent({
message: 'SpentTime: form:set-author',
analyticsId: ANALYTICS_ISSUE_STREAM_SECTION,
});
update({author});
updateSelectVisibility(false);
},
};
};
const getProjectTimeSettings = async (): Promise<ProjectTimeTrackingSettings> =>
await dispatch(issueActivityActions.getTimeTrackingSettings(draft.issue.project.id));
const getProjectTimeTrackingSettingsWorkTypes = (): ISelectProps<SelectPropsType> => {
return {
selectedItems: [],
dataSource: async () => {
const settings: ProjectTimeTrackingSettings = await getProjectTimeSettings();
return [getDefaultType(), ...settings.workItemTypes.sort(sortByOrdinal)];
},
onSelect: async (value: WorkItemType) => {
logEvent({
message: 'SpentTime: form:set-work-type',
analyticsId: ANALYTICS_ISSUE_STREAM_SECTION,
});
update({type: value});
updateSelectVisibility(false);
},
};
};
const getProjectTimeTrackingSettingsAttributes = (attributeId: string): ISelectProps<SelectPropsType> => {
return {
selectedItems: [],
dataSource: async () => {
const settings: ProjectTimeTrackingSettings = await getProjectTimeSettings();
return settings.attributes.find(a => a.id === attributeId)?.values || [];
},
onSelect: async (value: WorkItemAttributeValue) => {
logEvent({
message: 'SpentTime: form:set-custom-attribute',
analyticsId: ANALYTICS_ISSUE_STREAM_SECTION,
});
const attributes = draft.attributes!.reduce((akk: WorkItemAttribute[], a) => {
if (a.id === attributeId) {
a.value = value;
}
akk.push(a);
return akk;
}, []);
update({attributes});
updateSelectVisibility(false);
},
};
};
const onClose = () => {
confirmation(i18n('Discard draft and close?'), i18n('Discard and close'))
.then(() => {
logEvent({
message: 'SpentTime: form:cancel',
analyticsId: ANALYTICS_ISSUE_STREAM_SECTION,
});
dispatch(issueActivityActions.deleteWorkItemDraft(getIssueId()));
doHide();
})
.catch(() => null);
};
const onCreate = async () => {
const {onAdd = () => {}} = props;
logEvent({
message: 'SpentTime: form:submit',
analyticsId: ANALYTICS_ISSUE_STREAM_SECTION,
});
updateProgress(true);
const item = await dispatch(
issueActivityActions.submitWorkItem(
{
...draft,
$type: props.workItem ? draft.$type : undefined,
type: draft.type?.id ? draft.type : null,
},
getIssueId()
)
);
updateProgress(false);
if (item && hasType.work({$type: item.$type!})) {
onAdd();
doHide();
} else {
setHasError(true);
}
};
const renderHeader = () => {
const isSubmitDisabled: boolean = !draft.duration || !draft.author || !draft?.duration?.presentation || !spentTime;
const submitIcon = isProgress ? (
<ActivityIndicator color={styles.link.color} />
) : (
<IconCheck color={isSubmitDisabled ? styles.disabled.color : styles.link.color} />
);
return (
<Header
style={styles.elevation1}
title={i18n('Spent time')}
leftButton={<IconClose color={isProgress ? styles.disabled.color : styles.link.color} />}
onBack={() => !isProgress && onClose()}
extraButton={
<TouchableOpacity hitSlop={HIT_SLOP} disabled={isSubmitDisabled} onPress={onCreate}>
{submitIcon}
</TouchableOpacity>
}
/>
);
};
const buttonStyle: ViewStyleProp[] = [styles.feedbackFormInput, styles.feedbackFormType];
const iconAngleRight = <IconAngleRight size={20} color={styles.icon.color} />;
const author: User | null | undefined = draft?.author || currentUser;
const commonInputProps: Record<string, any> = {
autoCapitalize: 'none',
selectTextOnFocus: true,
autoCorrect: false,
placeholderTextColor: styles.icon.color,
keyboardAppearance: theme.uiTheme.name,
};
const renderDatePicker = () => {
return (
<DatePicker
date={draft.date ? new Date(draft.date) : new Date(Date.now())}
onDateSelect={(timestamp: number) => {
update({date: timestamp});
setDatePickerVisibility(false);
}}
/>
);
};
const renderResetButton = (onPress: () => void) => (
<TouchableOpacity hitSlop={HIT_SLOP2} onPress={onPress} style={styles.resetIcon}>
<IconClose size={21} color={styles.icon.color} />
</TouchableOpacity>
);
return (
<View style={styles.container}>
{renderHeader()}
<View style={styles.feedbackForm}>
<TouchableOpacity
style={buttonStyle}
disabled={!props.canCreateNotOwn}
onPress={() => {
updateSelectProps(getUserSelectProps(author));
updateSelectVisibility(true);
}}
>
<Text style={styles.feedbackFormTextSup}>{i18n('Author')}</Text>
<Text style={[styles.feedbackFormText, styles.feedbackFormTextMain]}>{getEntityPresentation(author)}</Text>
{props.canCreateNotOwn && iconAngleRight}
</TouchableOpacity>
<TouchableOpacity style={buttonStyle} onPress={() => setDatePickerVisibility(true)}>
<Text style={styles.feedbackFormTextSup}>{i18n('Date')}</Text>
<Text style={[styles.feedbackFormText, styles.feedbackFormTextMain]}>{absDate(draft.date, true)}</Text>
{iconAngleRight}
</TouchableOpacity>
<View style={buttonStyle}>
<Text style={[styles.feedbackFormTextSup, hasError && styles.feedbackFormTextError]}>
{i18n('Spent time')}
</Text>
<TextInput
{...commonInputProps}
style={[styles.feedbackInput, styles.feedbackFormTextMain]}
placeholder={i18n('1w 1d 1h 1m')}
value={spentTime}
onChangeText={(periodValue: string) => {
setHasError(false);
setSpentTime(periodValue);
updateDraftWorkItem({
...draft,
duration: {
presentation: periodValue,
},
});
}}
/>
</View>
{hasError && <Text style={styles.feedbackInputErrorHint}>{i18n('1w 1d 1h 1m')}</Text>}
<TouchableOpacity
style={buttonStyle}
onPress={() => {
updateSelectProps(getProjectTimeTrackingSettingsWorkTypes());
updateSelectVisibility(true);
}}
>
<Text style={styles.feedbackFormTextSup}>{i18n('Type')}</Text>
<Text style={[styles.feedbackFormText, styles.feedbackFormTextMain]} numberOfLines={1}>
{!!draft?.type?.color && <ColorBullet color={draft.type.color} />}
{draft.type?.name || <Text style={styles.placeholderText}>{getDefaultType().name}</Text>}
</Text>
{!!draft.type?.name && renderResetButton(() => {
update({type: null});
})}
{iconAngleRight}
</TouchableOpacity>
{!!draft.attributes?.length && (
<>
{draft.attributes.map(attr => (
<TouchableOpacity
key={attr.id}
style={buttonStyle}
onPress={() => {
updateSelectProps(getProjectTimeTrackingSettingsAttributes(attr.id));
updateSelectVisibility(true);
}}
>
<Text style={styles.feedbackFormTextSup}>{attr.name}</Text>
<Text style={[styles.feedbackFormText, styles.feedbackFormTextMain]} numberOfLines={1}>
{attr?.value?.color && <ColorBullet color={attr.value.color} />}
{attr?.value?.name || <Text style={styles.placeholderText}>{i18n('Select an option')}</Text>}
</Text>
<>
{!!attr?.value?.name &&
renderResetButton(() => {
update({
attributes: draft.attributes!.reduce((akk: WorkItemAttribute[], a) => {
akk.push(a.id === attr.id ? {...a, value: null} : a);
return akk;
}, []),
});
})}
{iconAngleRight}
</>
</TouchableOpacity>
))}
</>
)}
<TextInput
{...commonInputProps}
multiline
textAlignVertical="top"
style={[styles.feedbackFormInputMultiline, styles.commentInput]}
placeholder={i18n('Write a comment, @mention people')}
value={draft?.text || undefined}
onChangeText={(comment: string) => updateDraftWorkItem({...draft, text: comment})}
/>
<View style={styles.feedbackFormBottomIndent} />
</View>
{isSelectVisible && !!selectProps && renderSelect(selectProps)}
{isDatePickerVisible && <ModalView>{renderDatePicker()}</ModalView>}
</View>
);
};
export default memo<Props>(AddSpentTimeForm);
function createDraftWorkItem(issue: IssueFull, user: User): DraftWorkItem {
return {
date: Date.now(),
author: user,
creator: user,
duration: {
presentation: '1d',
},
type: getDefaultType(),
text: null,
usesMarkdown: true,
issue: {
id: issue.id,
project: {
id: issue.project.id,
ringId: issue.project.ringId,
},
},
};
}
function getDefaultType() {
return {
id: null,
name: i18n('No type'),
ordinal: 0,
};
}