packages/issue-dashboard-widgets/widgets/due-dates-calendar/app/due_dates_calendar_widget.js (658 lines of code) (raw):

/* eslint-disable max-len */ /* eslint-disable complexity */ /* eslint-disable react/no-access-state-in-setstate */ import React from 'react'; import PropTypes from 'prop-types'; import LoaderInline from '@jetbrains/ring-ui/components/loader-inline/loader-inline'; import PermissionCache from '@jetbrains/ring-ui/components/permissions/permissions__cache'; import {i18n} from 'hub-dashboard-addons/dist/localization'; import EmptyWidget, {EmptyWidgetFaces} from '@jetbrains/hub-widget-ui/dist/empty-widget'; import ConfigurableWidget from '@jetbrains/hub-widget-ui/dist/configurable-widget'; import {Calendar} from 'react-big-calendar'; import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop'; import moment from 'moment'; import './style/calendar.scss'; import classNames from 'classnames'; import styles from './app.module.css'; import EditForm from './edit-form'; import { loadIssues, loadTotalIssuesCount, loadProfile, loadConfigL10n, updateIssueScheduleField, loadPermissionCache } from './resources'; import ServiceResource from './components/service-resource'; import customMoment from './custom-localizer'; import EventComponent from './issue_event'; import CalendarToolbar from './calendar_toolbar'; const DragAndDropCalendar = withDragAndDrop(Calendar); const DEFAULT_SCHEDULE_FIELD = 'Due Date'; const DEFAULT_COLOR_FIELD = 'Priority'; const DATE_FIELD_TYPE = 'date'; const DATE_AND_TIME_FIELD_TYPE = 'date and time'; const STATE_FIELD_NAME = 'State'; const ASSIGNEE_FIELD_NAME = 'Assignee'; const MIDDAY = 12; class DueDatesCalendarWidget extends React.Component { static DEFAULT_REFRESH_PERIOD = 240; // eslint-disable-line no-magic-numbers static digitToUnicodeSuperScriptDigit = digitSymbol => { const unicodeSuperscriptDigits = [ 0x2070, 0x00B9, 0x00B2, 0x00B3, 0x2074, // eslint-disable-line no-magic-numbers 0x2075, 0x2076, 0x2077, 0x2078, 0x2079 // eslint-disable-line no-magic-numbers ]; return String.fromCharCode(unicodeSuperscriptDigits[Number(digitSymbol)]); }; static getIssueListLink = (homeUrl, context, search) => { let link = `${homeUrl}/`; if (context && context.shortName) { link += `issues/${context.shortName.toLowerCase()}`; } else if (context && context.$type) { if (context.$type.toLowerCase().indexOf('tag') > -1) { link += `tag/${context.name.toLowerCase()}-${context.id.split('-').pop()}`; } else { link += `search/${context.name.toLowerCase()}-${context.id.split('-').pop()}`; } } else { link += 'issues'; } if (search) { link += `?q=${encodeURIComponent(search)}`; } return link; }; static getFullSearchPresentation = (context, search) => [ context && context.name && `#{${context.name}}`, search ].filter(str => !!str).join(' ') || `#${i18n('issues')}`; static getDefaultYouTrackService = async (dashboardApi, predefinedYouTrack) => { if (predefinedYouTrack && predefinedYouTrack.id) { return predefinedYouTrack; } try { // TODO: pass min-required version here return await ServiceResource.getYouTrackService( dashboardApi.fetchHub.bind(dashboardApi) ); } catch (err) { return null; } }; static youTrackServiceNeedsUpdate = service => !service.name; static getDefaultWidgetTitle = () => i18n('Due Date Calendar Widget'); static getWidgetTitle = (search, context, title, issuesCount, youTrack, scheduleField, eventEndField) => { const issuesQuery = scheduleField === eventEndField ? `has: {${scheduleField}}` : `has: {${scheduleField}} and has: {${eventEndField}}`; let displayedTitle = title || `${DueDatesCalendarWidget.getFullSearchPresentation(context, search)} ${issuesQuery}`; if (issuesCount) { const superScriptIssuesCount = `${issuesCount}`.split('').map(DueDatesCalendarWidget.digitToUnicodeSuperScriptDigit).join(''); displayedTitle += ` ${superScriptIssuesCount}`; } return { text: displayedTitle, href: youTrack && DueDatesCalendarWidget.getIssueListLink( youTrack.homeUrl, context, `${search} ${issuesQuery}` ) }; }; static propTypes = { dashboardApi: PropTypes.object, configWrapper: PropTypes.object, registerWidgetApi: PropTypes.func }; constructor(props) { super(props); const {registerWidgetApi} = props; this.state = { isConfiguring: false, isLoading: true, events: [], date: new Date(), localizer: customMoment(moment) }; registerWidgetApi({ onConfigure: () => this.setState({ isConfiguring: true, isLoading: false, isLoadDataError: false, isEmptyQueryResultError: false }), onRefresh: () => this.loadIssues() }); } componentDidMount() { this.initialize(this.props.dashboardApi); } initialize = async dashboardApi => { this.setState({isLoading: true}); await this.props.configWrapper.init(); const youTrackService = await DueDatesCalendarWidget.getDefaultYouTrackService( dashboardApi, this.props.configWrapper.getFieldValue('youTrack') ); if (this.props.configWrapper.isNewConfig()) { this.initializeNewWidget(youTrackService); } else { await this.initializeExistingWidget(youTrackService); } await this.setLocaleOptions(); }; async setLocaleOptions() { const profile = await loadProfile(this.fetchYouTrack); const firstDayOfWeek = profile.profiles.appearance.firstDayOfWeek; const profileLocale = profile.profiles.general.locale.locale; moment.locale('en-gb', { week: { dow: firstDayOfWeek } }); this.setState({localizer: customMoment(moment), profileLocale}); } initializeNewWidget(youTrackService) { if (youTrackService && youTrackService.id) { this.setState({ isConfiguring: true, isNew: true, youTrack: youTrackService, isLoading: false }); } this.setState({isLoadDataError: true, isLoading: false}); } // eslint-disable-next-line complexity async initializeExistingWidget(youTrackService) { const search = this.props.configWrapper.getFieldValue('search'); const context = this.props.configWrapper.getFieldValue('context'); const refreshPeriod = this.props.configWrapper.getFieldValue('refreshPeriod'); const title = this.props.configWrapper.getFieldValue('title'); const view = this.props.configWrapper.getFieldValue('view'); const scheduleField = this.props.configWrapper.getFieldValue('scheduleField') || DEFAULT_SCHEDULE_FIELD; const eventEndField = this.props.configWrapper.getFieldValue('eventEndField') || scheduleField; const colorField = this.props.configWrapper.getFieldValue('colorField') || DEFAULT_COLOR_FIELD; const isDateAndTime = this.props.configWrapper.getFieldValue('isDateAndTime'); const canResize = scheduleField !== eventEndField; this.setState({ title, search: search || '', context, date: new Date(), view, scheduleField, eventEndField, isDateAndTime, colorField, canResize, refreshPeriod: refreshPeriod || DueDatesCalendarWidget.DEFAULT_REFRESH_PERIOD }); await this.showListFromCache(search, context); if (youTrackService && youTrackService.id) { const onYouTrackSpecified = async () => { await this.loadIssues(search, context); this.setState({isLoading: false}); }; this.setYouTrack(youTrackService, onYouTrackSpecified); } } async showListFromCache(search, context) { const {dashboardApi} = this.props; const cache = (await dashboardApi.readCache() || {}).result; if (cache && cache.search === search && (cache.context || {}).id === (context || {}).id) { this.setState({issues: cache.issues, fromCache: true}); } } setYouTrack(youTrackService, onAfterYouTrackSetFunction) { const {homeUrl} = youTrackService; this.setState({ youTrack: { id: youTrackService.id, homeUrl } }, async () => await onAfterYouTrackSetFunction()); if (DueDatesCalendarWidget.youTrackServiceNeedsUpdate(youTrackService)) { const {dashboardApi} = this.props; ServiceResource.getYouTrackService( dashboardApi.fetchHub.bind(dashboardApi), youTrackService.id ).then( updatedYouTrackService => { const shouldReSetYouTrack = updatedYouTrackService && !DueDatesCalendarWidget.youTrackServiceNeedsUpdate( updatedYouTrackService ) && updatedYouTrackService.homeUrl !== homeUrl; if (shouldReSetYouTrack) { this.setYouTrack( updatedYouTrackService, onAfterYouTrackSetFunction ); if (!this.state.isConfiguring) { this.props.configWrapper.update({ youTrack: { id: updatedYouTrackService.id, homeUrl: updatedYouTrackService.homeUrl } }); } } } ); } } submitConfiguration = async formParameters => { const { search, title, context, refreshPeriod, selectedYouTrack, scheduleField, eventEndField, colorField, isDateAndTime } = formParameters; this.setYouTrack( selectedYouTrack, async () => { this.setState( {search: search || '', context, title, scheduleField, eventEndField, refreshPeriod, colorField, isDateAndTime}, async () => { await this.loadIssues(); await this.props.configWrapper.replace({ search, context, title, refreshPeriod, scheduleField, eventEndField, colorField, isDateAndTime, youTrack: { id: selectedYouTrack.id, homeUrl: selectedYouTrack.homeUrl } }); // eslint-disable-next-line max-len const canResize = this.state.scheduleField !== this.state.eventEndField; this.setState( {isConfiguring: false, fromCache: false, isNew: false, canResize} ); } ); } ); }; cancelConfiguration = async () => { if (this.state.isNew) { await this.props.dashboardApi.removeWidget(); } else { this.setState({isConfiguring: false}); await this.props.dashboardApi.exitConfigMode(); this.initialize(this.props.dashboardApi); } }; initRefreshPeriod = newRefreshPeriod => { if (newRefreshPeriod !== this.state.refreshPeriod) { this.setState({refreshPeriod: newRefreshPeriod}); } const millisInSec = 1000; setTimeout(async () => { const { isConfiguring, refreshPeriod, search, context, scheduleField, eventEndField } = this.state; if (!isConfiguring && refreshPeriod === newRefreshPeriod) { await this.loadIssues(search, context, scheduleField, eventEndField); this.initRefreshPeriod(refreshPeriod); } }, newRefreshPeriod * millisInSec); }; fetchYouTrack = async (url, params) => { const {dashboardApi} = this.props; const {youTrack} = this.state; return await dashboardApi.fetch(youTrack.id, url, params); }; fetchHub = async (url, params) => { const {dashboardApi} = this.props; return await dashboardApi.fetchHub(url, params); }; renderConfiguration = () => ( <div className={`issues-list-widget ${styles.widget}`}> <EditForm search={this.state.search} context={this.state.context} title={this.state.title} refreshPeriod={this.state.refreshPeriod} scheduleField={this.state.scheduleField || DEFAULT_SCHEDULE_FIELD} eventEndField={this.state.eventEndField || this.state.scheduleField || DEFAULT_SCHEDULE_FIELD} colorField={this.state.colorField || DEFAULT_COLOR_FIELD} onSubmit={this.submitConfiguration} onCancel={this.cancelConfiguration} dashboardApi={this.props.dashboardApi} youTrackId={this.state.youTrack.id} /> </div> ); renderLoadDataError() { return ( <EmptyWidget face={EmptyWidgetFaces.ERROR} message={i18n('Can\'t load information from service.')} /> ); } renderEmptyQueryResultError() { return ( <EmptyWidget face={EmptyWidgetFaces.ERROR} message={i18n('No issues corresponding your query, project and schedule field.')} /> ); } async loadIssues(search, context, scheduleField, eventEndField) { const currentSearch = search || this.state.search; const currentContext = context || this.state.context; const currentScheduleField = scheduleField || this.state.scheduleField; const currentEventEndField = eventEndField || this.state.eventEndField; try { const issuesQuery = currentScheduleField === currentEventEndField ? `has: {${currentScheduleField}}` : `has: {${currentScheduleField}} and has: {${currentEventEndField}}`; await this.loadIssuesCount(`${currentSearch} ${issuesQuery}`, currentContext); } catch (error) { this.setState({isEmptyQueryResultError: true, issuesCount: 0}); } try { await this.loadIssuesUnsafe( currentSearch, currentContext, currentScheduleField, currentEventEndField); } catch (error) { this.setState({isLoadDataError: true}); } await this.setLocaleOptions(); } renderLoader() { return <LoaderInline/>; } async loadIssuesUnsafe(search, context, scheduleField, eventEndField) { const currentDate = moment(this.state.date); const startDate = moment(currentDate).startOf('month').startOf('week').format('YYYY-MM-DD'); const endDate = moment(currentDate).endOf('month').endOf('week').format('YYYY-MM-DD'); const searchPrefix = search && search.trim() ? `${search} AND` : ''; const issuesQuery = scheduleField === eventEndField ? `${searchPrefix} ${scheduleField}: ${startDate} .. ${endDate}` : `${searchPrefix} ((${scheduleField}: ${startDate} .. ${endDate} or ${eventEndField}: ${startDate} .. ${endDate}) or (${scheduleField}: * .. ${startDate} and ${eventEndField}: ${endDate} .. *))`; const isDateAndTime = this.state.isDateAndTime; const issues = await loadIssues( this.fetchYouTrack, issuesQuery, context ); const permCache = new PermissionCache( await loadPermissionCache(this.fetchHub)); const events = []; if (Array.isArray(issues)) { issues.forEach(issue => { let issueScheduleField = ''; let issueScheduleFieldDbId = ''; let issueEventEndField = ''; let issueEventEndFieldDbId = ''; let issueAssignee = ''; let foregroundColor = '#9c9c9c'; let backgroundColor = '#e8e8e8'; let issuePriority = ''; let isResolved = false; const customFields = []; // eslint-disable-next-line complexity issue.fields.forEach(field => { if (field.hasOwnProperty('projectCustomField') && field.value) { const fieldType = field.projectCustomField.field.fieldType.valueType; // eslint-disable-next-line max-len if (fieldType === DATE_FIELD_TYPE && !isDateAndTime || fieldType === DATE_AND_TIME_FIELD_TYPE && isDateAndTime) { // eslint-disable-next-line max-len if (field.projectCustomField.field.name === scheduleField || field.projectCustomField.field.localizedName === scheduleField) { issueScheduleField = field.value; issueScheduleFieldDbId = field.id; } // eslint-disable-next-line max-len if (field.projectCustomField.field.name === eventEndField || field.projectCustomField.field.localizedName === eventEndField) { issueEventEndField = field.value; issueEventEndFieldDbId = field.id; } } // eslint-disable-next-line max-len if (field.projectCustomField.field.name === ASSIGNEE_FIELD_NAME || field.projectCustomField.field.localizedName === ASSIGNEE_FIELD_NAME) { issueAssignee = field.value; } // eslint-disable-next-line max-len if (field.projectCustomField.field.name === this.state.colorField || field.projectCustomField.field.localizedName === this.state.colorField) { issuePriority = field.value.name; foregroundColor = field.value.color.foreground; backgroundColor = field.value.color.background; } else if (field.value.color) { const prjCustomField = field.projectCustomField; customFields.push({ name: prjCustomField.localizedName !== null ? prjCustomField.localizedName : prjCustomField.field.name, value: field.value.name, foregroundColor: field.value.color.foreground, backgroundColor: field.value.color.background }); } if (field.projectCustomField.field.name === STATE_FIELD_NAME) { // eslint-disable-next-line max-len isResolved = Boolean(field.value.isResolved); } } }); if (issueScheduleField !== '') { events.push({ dbIssueId: issue.id, issueId: issue.idReadable, description: `${issue.idReadable} ${issue.summary}`, url: `${this.state.youTrack.homeUrl}/issue/${issue.idReadable}`, priority: issuePriority, isResolved, issueScheduleFieldDbId, // eslint-disable-next-line max-len issueEventEndFieldDbId: issueEventEndFieldDbId !== '' ? issueEventEndFieldDbId : issueScheduleFieldDbId, start: (new Date(issueScheduleField)), // eslint-disable-next-line max-len end: (issueEventEndField !== '' ? new Date(issueEventEndField) : new Date(issueScheduleField)), allDay: !this.state.isDateAndTime, foregroundColor, backgroundColor, customFields, issueAssignee, isUpdatable: permCache.has('JetBrains.YouTrack.UPDATE_ISSUE', issue.project.ringId), ytHomeUrl: this.state.youTrack.homeUrl }); } }); } this.setState({issues, events, fromCache: false, isLoadDataError: false}); this.props.dashboardApi.storeCache({ search, context, issues }); } async loadIssuesCount(search, context) { const issuesCount = await loadTotalIssuesCount( this.fetchYouTrack, search, context ); this.changeIssuesCount(issuesCount); } changeIssuesCount = issuesCount => { this.setState({issuesCount}); }; calendarNavigate = async date => { this.setState({date}, this.loadIssues); const config = await this.props.dashboardApi.readConfig(); config.date = date; await this.props.dashboardApi.storeConfig(config); }; calendarChangeView = async view => { this.setState({view}); const config = await this.props.dashboardApi.readConfig(); config.view = view; await this.props.dashboardApi.storeConfig(config); }; // eslint-disable-next-line no-unused-vars handleSelect = async ({start, end}) => { const { context, isDateAndTime, youTrack, scheduleField } = this.state; const projectName = context.shortName; if (projectName) { let format = 'YYYY-MM-DD'; if (isDateAndTime) { const l10nConfig = await loadConfigL10n(this.fetchYouTrack); const predefinedQueries = l10nConfig.l10n.predefinedQueries; const sep = predefinedQueries['DateTime Separator'] ? predefinedQueries['DateTime Separator'] : 'T'; format = `YYYY-MM-DD[${sep}]HH:mm:ss`; } const slotTime = moment(start).format(format); window.open( `${youTrack.homeUrl}/newIssue?project=${encodeURIComponent(projectName)}&c=${encodeURIComponent(scheduleField)} ${slotTime}`); } }; moveEvent = async ({event, start}) => { const {events} = this.state; const prevEvents = events; //calculate correct end to avoid issue with event prolongation on drag const newEnd = event.end.getTime() + start.getTime() - event.start.getTime(); const idx = events.indexOf(event); const updatedEvent = {...event, start, end: new Date(newEnd)}; const updatedEvents = [...events]; updatedEvents.splice(idx, 1, updatedEvent); this.setState({ events: updatedEvents }); try { // update start date const newStartTime = this.state.isDateAndTime ? start.getTime() : toUtcMidday(start); await updateIssueScheduleField( this.fetchYouTrack, event.dbIssueId, event.issueScheduleFieldDbId, newStartTime); // update event end date if field different if (event.issueEventEndFieldDbId !== event.issueScheduleFieldDbId) { await updateIssueScheduleField( this.fetchYouTrack, event.dbIssueId, event.issueEventEndFieldDbId, newEnd); } } catch (error) { this.setState({ events: prevEvents }); } } resizeEvent = async ({event, start, end}) => { const {events} = this.state; const prevEvents = events; const idx = events.indexOf(event); const updatedEvent = {...event, start, end}; const updatedEvents = [...events]; updatedEvents.splice(idx, 1, updatedEvent); this.setState({ events: updatedEvents }); try { const newStartTime = this.state.isDateAndTime ? start.getTime() : toUtcMidday(start); // update start date await updateIssueScheduleField( this.fetchYouTrack, event.dbIssueId, event.issueScheduleFieldDbId, newStartTime); // update event end date if field different if (event.issueEventEndFieldDbId !== event.issueScheduleFieldDbId) { const newEndTime = this.state.isDateAndTime ? end.getTime() : toUtcMidday(new Date(end)); await updateIssueScheduleField( this.fetchYouTrack, event.dbIssueId, event.issueEventEndFieldDbId, newEndTime); } } catch (error) { this.setState({ events: prevEvents }); } } eventUpdatable = event => event.isUpdatable canResize = () => this.state.canResize // eslint-disable-next-line complexity renderContent = () => { const { isConfiguring, isLoading, fromCache, isLoadDataError, isEmptyQueryResultError } = this.state; if (isEmptyQueryResultError) { return this.renderEmptyQueryResultError(); } if (isLoadDataError && !fromCache) { return this.renderLoadDataError(); } if (isConfiguring) { return this.renderConfiguration(); } if (isLoading && !fromCache) { return this.renderLoader(); } const calendarClasses = classNames({ [`${styles.calendar}`]: true, 'date-only-calendar': !this.state.isDateAndTime }); return ( <div className={styles.widget}> <DragAndDropCalendar selectable localizer={this.state.localizer} defaultDate={this.state.date} defaultView={this.state.view} events={this.state.events} draggableAccessor={this.eventUpdatable} onEventDrop={this.moveEvent} onEventResize={this.resizeEvent} resizableAccessor={this.canResize} className={calendarClasses} views={['month', 'week', 'day']} culture={this.state.profileLocale} components={ { toolbar: CalendarToolbar, event: EventComponent } } onNavigate={this.calendarNavigate} onView={this.calendarChangeView} onSelectSlot={this.handleSelect} messages={{ showMore: total => `+ ${total} ${i18n('more')}` }} /> </div> ); }; // eslint-disable-next-line complexity render() { const { isConfiguring, search, context, title, issuesCount, youTrack, scheduleField, eventEndField } = this.state; const widgetTitle = isConfiguring ? DueDatesCalendarWidget.getDefaultWidgetTitle() : DueDatesCalendarWidget.getWidgetTitle( search, context, title, issuesCount, youTrack, scheduleField, eventEndField ); return ( <ConfigurableWidget isConfiguring={isConfiguring} dashboardApi={this.props.dashboardApi} widgetTitle={widgetTitle} widgetLoader={this.state.isLoading} Configuration={this.renderConfiguration} Content={this.renderContent} /> ); } } function toUtcMidday(date) { // eslint-disable-next-line max-len return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), MIDDAY, 0, 0, 0); } export default DueDatesCalendarWidget;