diff --git a/client/src/components/CustomDateInput.js b/client/src/components/CustomDateInput.js index 45399c5b41..2d8d17da35 100644 --- a/client/src/components/CustomDateInput.js +++ b/client/src/components/CustomDateInput.js @@ -30,18 +30,21 @@ const CustomDateInput = ({ withTime, value, onChange, - onBlur + onBlur, + fullWidth, + allDay }) => { const inputRef = useRef() const rightElement = showIcon && CalendarIcon(inputRef.current) const width = 8 + (showIcon ? 3 : 0) + (withTime ? 3 : 0) - const style = { width: `${width}em`, fontSize: "1.1em" } - const dateFormats = withTime - ? Settings.dateFormats.forms.input.withTime - : Settings.dateFormats.forms.input.date + const style = { width: fullWidth ? "100%" : `${width}em`, fontSize: "1.1em" } + const dateFormats = + withTime && !allDay + ? Settings.dateFormats.forms.input.withTime + : Settings.dateFormats.forms.input.date const inputFormat = dateFormats[0] const timePickerProps = !withTime - ? {} + ? undefined : { precision: TimePrecision.MINUTE, selectAllOnFocus: true @@ -70,6 +73,7 @@ const CustomDateInput = ({ timePickerProps={timePickerProps} popoverProps={{ usePortal: false }} disabled={disabled} + fill={fullWidth} /> ) } @@ -84,12 +88,16 @@ CustomDateInput.propTypes = { PropTypes.instanceOf(Date) ]), onChange: PropTypes.func, - onBlur: PropTypes.func + onBlur: PropTypes.func, + fullWidth: PropTypes.bool, + allDay: PropTypes.bool } CustomDateInput.defaultProps = { disabled: false, showIcon: true, - withTime: false + withTime: false, + fullWidth: false, + allDay: true } export default CustomDateInput diff --git a/client/src/components/ReportSummary.js b/client/src/components/ReportSummary.js index 33151a7a2e..292b855430 100644 --- a/client/src/components/ReportSummary.js +++ b/client/src/components/ReportSummary.js @@ -245,9 +245,7 @@ const ReportSummaryRow = ({ report }) => { {report.engagementDate && ( )} diff --git a/client/src/components/ReportTable.js b/client/src/components/ReportTable.js index 9095eb59e2..cde595694d 100644 --- a/client/src/components/ReportTable.js +++ b/client/src/components/ReportTable.js @@ -6,7 +6,6 @@ import UltimatePaginationTopDown from "components/UltimatePaginationTopDown" import _get from "lodash/get" import _isEqual from "lodash/isEqual" import { Report } from "models" -import moment from "moment" import PropTypes from "prop-types" import React, { useEffect, useRef, useState } from "react" import { Table } from "react-bootstrap" @@ -172,11 +171,7 @@ const ReportTable = ({ /> {showStatus && {report.state}} - - {moment(report.engagementDate).format( - Report.getEngagementDateFormat() - )} - + {Report.getFormattedEngagementDate(report)} ))} diff --git a/client/src/components/aggregations/ReportsMapWidget.js b/client/src/components/aggregations/ReportsMapWidget.js index 5229366153..4d53dff653 100644 --- a/client/src/components/aggregations/ReportsMapWidget.js +++ b/client/src/components/aggregations/ReportsMapWidget.js @@ -5,7 +5,7 @@ import { import Leaflet from "components/Leaflet" import _escape from "lodash/escape" import _isEmpty from "lodash/isEmpty" -import { Location } from "models" +import { Location, Report } from "models" import PropTypes from "prop-types" import React, { useMemo } from "react" @@ -25,7 +25,9 @@ const ReportsMapWidget = ({ values.forEach(report => { if (Location.hasCoordinates(report.location)) { let label = _escape(report.intent || "") // escape HTML in intent! - label += `
@ ${_escape(report.location.name)}` // escape HTML in locationName! + label += `
@ ${_escape( + report.location.name + )} ${Report.getAllDayIndicator(report)}` // escape HTML in locationName! markerArray.push({ id: report.uuid, lat: report.location.lat, diff --git a/client/src/components/aggregations/utils.js b/client/src/components/aggregations/utils.js index 52650f16ff..6e1b62e273 100644 --- a/client/src/components/aggregations/utils.js +++ b/client/src/components/aggregations/utils.js @@ -210,7 +210,7 @@ export function reportsToEvents(reports) { (r.location && r.location.name) || "" return { - title: who + "@" + where, + title: `${who} @ ${where} - ${Report.getAllDayIndicator(r)}`, start: moment(r.engagementDate).format("YYYY-MM-DD HH:mm"), end: moment(r.engagementDate) .add(r.duration, "minutes") diff --git a/client/src/index.css b/client/src/index.css index af772f7e44..c9c1981ed1 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -148,7 +148,7 @@ fieldset.danger { } #duration { - width: 10em; + width: 100%; } .shortcut-list { diff --git a/client/src/models/Report.js b/client/src/models/Report.js index 2136401375..05af171b40 100644 --- a/client/src/models/Report.js +++ b/client/src/models/Report.js @@ -106,7 +106,26 @@ export default class Report extends Model { .nullable() .required("You must provide the Date of Engagement") .default(null), - duration: yup.number().nullable().default(null), + duration: yup + .number() + .nullable() + .test( + "duration", + "You must provide duration when engagement time(hour:minute) is provided", + function(duration) { + const { engagementDate } = this.parent + if ( + !engagementDate || + moment(engagementDate).isSame( + moment(engagementDate).startOf("day") + ) + ) { + return true + } + return !!duration + } + ) + .default(null), // not actually in the database, but used for validation: cancelled: yup .boolean() @@ -570,16 +589,24 @@ export default class Report extends Model { ) } + static isEngagementAllDay(report) { + return !report.duration + } + + static getAllDayIndicator(report) { + return Report.isEngagementAllDay(report) ? " (all day)" : "" + } + static getFormattedEngagementDate(report) { if (!report?.engagementDate) { return "" } const start = moment(report.engagementDate) - if (!report.duration) { + if (Report.isEngagementAllDay(report)) { return Settings.engagementsIncludeTimeAndDuration ? start.format(Settings.dateFormats.forms.displayLong.date) + - " (all day)" + Report.getAllDayIndicator(report) : start.format(Report.getEngagementDateFormat()) } diff --git a/client/src/pages/reports/EngagementDateFormPartial.js b/client/src/pages/reports/EngagementDateFormPartial.js new file mode 100644 index 0000000000..aaadb4d901 --- /dev/null +++ b/client/src/pages/reports/EngagementDateFormPartial.js @@ -0,0 +1,187 @@ +import { Checkbox } from "@blueprintjs/core" +import CustomDateInput from "components/CustomDateInput" +import * as FieldHelper from "components/FieldHelper" +import { FastField, Field } from "formik" +import { Report } from "models" +import moment from "moment" +import PropTypes from "prop-types" +import React, { useState } from "react" +import { Col, ControlLabel, FormGroup, HelpBlock } from "react-bootstrap" +import Settings from "settings" +import utils from "utils" + +const futureEngagementHint = ( + + This will create a planned engagement + +) + +function isStartOfDay(date) { + return date && moment(date).isSame(moment(date).startOf("day")) +} + +const EngagementDatePartialFormWithDuration = ({ + setFieldValue, + setFieldTouched, + validateFieldDebounced, + initialValues, + edit, + values +}) => { + const [isAllDay, setIsAllDay] = useState(() => + getInitalAllDayState(edit, initialValues) + ) + + return ( + + + Engagement planning + + + { + const sval = value ? moment(value).startOf("minute").toDate() : null + setFieldTouched("engagementDate", true, false) // onBlur doesn't work when selecting a date + setFieldValue("engagementDate", sval, true) + if (!sval) { + setIsAllDay(true) + } else if (!isStartOfDay(sval)) { + setIsAllDay(false) + } + }} + onBlur={() => setFieldTouched("engagementDate")} + widget={ + + } + vertical + > + {Report.isFuture(values.engagementDate) && futureEngagementHint} + + + + event.currentTarget.blur()} // Prevent scroll action on number input + onChange={event => { + const safeVal = + utils.preventNonPositiveAndLongDigits(event.target.value, 4) || + null + setFieldTouched("duration", true, false) + setFieldValue("duration", safeVal, false) + validateFieldDebounced("duration") + setIsAllDay(false) + }} + vertical + disabled={isAllDay} + /> + + + { + setIsAllDay(e.target.checked) + if (e.target.checked) { + setFieldValue("duration", null, true) + validateFieldDebounced("duration") + if (values.engagementDate) { + setFieldValue( + "engagementDate", + moment(values.engagementDate).startOf("day").toDate(), + false + ) + } + } + }} + /> + + + ) +} + +function getInitalAllDayState(edit, initialValues) { + if (!edit || !initialValues.engagementDate) { + return true + } else if (!isStartOfDay(initialValues.engagementDate)) { + return false + } else { + return initialValues.duration === null + } +} + +EngagementDatePartialFormWithDuration.propTypes = { + setFieldValue: PropTypes.func.isRequired, + setFieldTouched: PropTypes.func.isRequired, + validateFieldDebounced: PropTypes.func.isRequired, + values: PropTypes.object.isRequired, + initialValues: PropTypes.instanceOf(Report).isRequired, + edit: PropTypes.bool.isRequired +} + +const EngagementDateFormPartial = ({ + setFieldValue, + setFieldTouched, + validateFieldDebounced, + initialValues, + edit, + values +}) => { + if (!Settings.engagementsIncludeTimeAndDuration) { + return ( + { + const val = value ? moment(value).startOf("day").toDate() : null + setFieldValue("engagementDate", val, true) + }} + widget={} + > + {Report.isFuture(values.engagementDate) && futureEngagementHint} + + ) + } + + return ( + + ) +} + +EngagementDateFormPartial.propTypes = { + setFieldValue: PropTypes.func.isRequired, + setFieldTouched: PropTypes.func.isRequired, + validateFieldDebounced: PropTypes.func.isRequired, + values: PropTypes.object.isRequired, + initialValues: PropTypes.instanceOf(Report).isRequired, + edit: PropTypes.bool.isRequired +} + +export default EngagementDateFormPartial diff --git a/client/src/pages/reports/Form.js b/client/src/pages/reports/Form.js index e715b70f80..59f6868f31 100644 --- a/client/src/pages/reports/Form.js +++ b/client/src/pages/reports/Form.js @@ -13,7 +13,6 @@ import AdvancedSingleSelect from "components/advancedSelectWidget/AdvancedSingle import AppContext from "components/AppContext" import InstantAssessmentsContainerField from "components/assessments/InstantAssessmentsContainerField" import ConfirmDelete from "components/ConfirmDelete" -import CustomDateInput from "components/CustomDateInput" import { CustomFieldsContainer, customFieldsJSONString @@ -45,11 +44,12 @@ import _isEqual from "lodash/isEqual" import _upperFirst from "lodash/upperFirst" import { AuthorizationGroup, Location, Person, Report, Tag, Task } from "models" import moment from "moment" +import EngagementDateFormPartial from "pages/reports/EngagementDateFormPartial" import { RECURRENCE_TYPE } from "periodUtils" import pluralize from "pluralize" import PropTypes from "prop-types" import React, { useContext, useEffect, useRef, useState } from "react" -import { Button, Checkbox, Collapse, HelpBlock } from "react-bootstrap" +import { Button, Checkbox, Collapse } from "react-bootstrap" import { connect } from "react-redux" import { useHistory } from "react-router-dom" import { toast } from "react-toastify" @@ -500,49 +500,14 @@ const ReportForm = ({ className="meeting-goal" /> - { - setFieldTouched("engagementDate", true, false) // onBlur doesn't work when selecting a date - setFieldValue("engagementDate", value, true) - }} - onBlur={() => setFieldTouched("engagementDate")} - widget={ - - } - > - {isFutureEngagement && ( - - - This will create a planned engagement - - - )} - - - {Settings.engagementsIncludeTimeAndDuration && ( - event.currentTarget.blur()} // Prevent scroll action on number input - onChange={event => { - const safeVal = - utils.preventNegativeAndLongDigits( - event.target.value, - 4 - ) || null - setFieldTouched("duration", true, false) - setFieldValue("duration", safeVal, false) - validateFieldDebounced("duration") - }} - /> - )} + { component={FieldHelper.ReadonlyField} humanValue={ <> - {report.engagementDate && - moment(report.engagementDate).format( - Report.getEngagementDateFormat() - )} + {Report.getFormattedEngagementDate(report)} } diff --git a/client/src/utils.js b/client/src/utils.js index 825dd60317..816423f9cd 100644 --- a/client/src/utils.js +++ b/client/src/utils.js @@ -199,11 +199,11 @@ export default { ) }, - preventNegativeAndLongDigits: function(valueStr, maxLen) { + preventNonPositiveAndLongDigits: function(valueStr, maxLen) { let safeVal const dangerVal = Number(valueStr) - if (!isNaN(dangerVal) && dangerVal < 0) { - safeVal = "0" + if (!isNaN(dangerVal) && dangerVal <= 0) { + safeVal = null } else { const nonDigitsRemoved = valueStr.replace(/\D/g, "") safeVal = diff --git a/client/tests/e2e/report.js b/client/tests/e2e/report.js index 357b6b1565..2bffd4a184 100644 --- a/client/tests/e2e/report.js +++ b/client/tests/e2e/report.js @@ -32,8 +32,11 @@ test.serial("Draft and submit a report", async t => { await pageHelpers.clickTodayButton() - const $intent = await $("#intent") - await $intent.click() // click intent to make sure the date picker is being closed + const $intent = await $('label[for="intent"]') + await $intent.click() // click intent label to make sure the date picker is being closed + + await pageHelpers.writeInForm("#duration", "30") + await t.context.driver.sleep(shortWaitMs) // wait for the datepicker to pop up const $locationAdvancedSelect = await pageHelpers.chooseAdvancedSelectOption( "#location", @@ -445,7 +448,7 @@ test.serial( ) await $input.sendKeys("user input") - await $input.sendKeys(t.context.Key.TAB) // fire blur event + await $searchBarInput.click() // fire blur event t.false( _includes(await $fieldGroup.getAttribute("class"), warningClass), `After typing in ${fieldName} field, warning state goes away` diff --git a/client/tests/webdriver/baseSpecs/createReportWithPlanningConflict.spec.js b/client/tests/webdriver/baseSpecs/createReportWithPlanningConflict.spec.js index ec9a8d6e9f..a84b7fd708 100644 --- a/client/tests/webdriver/baseSpecs/createReportWithPlanningConflict.spec.js +++ b/client/tests/webdriver/baseSpecs/createReportWithPlanningConflict.spec.js @@ -4,13 +4,53 @@ import CreateReport from "../pages/report/createReport.page" import EditReport from "../pages/report/editReport.page" import ShowReport from "../pages/report/showReport.page" +// NOTE: Copied Report model logic here because importing issues + +function getFormattedDateInput(report) { + return report.engagementDate.format("DD-MM-YYYY HH:mm") +} + +function isEngagementAllDay(report) { + return !report.duration +} + +function getEngagementDateFormat() { + return "dddd, D MMMM YYYY @ HH:mm" +} + +function getAllDayIndicator(report) { + return isEngagementAllDay(report) ? " (all day)" : "" +} + +function getFormattedEngagementDate(report) { + if (!report?.engagementDate) { + return "" + } + + const start = moment(report.engagementDate) + if (isEngagementAllDay(report)) { + return start.format("dddd, D MMMM YYYY") + getAllDayIndicator(report) + } + + const end = moment(report.engagementDate).add(report.duration, "minutes") + + return ( + start.format(getEngagementDateFormat()) + + end.format( + start.isSame(end, "day") + ? " - HH:mm" + : " >>> " + getEngagementDateFormat() + ) + ) +} + describe("When creating a Report with conflicts", () => { let firstReportUUID let secondReportUUID const report01 = { intent: "111111111111", engagementDate: moment() - .add(1, "day") + .add(2, "day") .hours(1) .minutes(0) .seconds(0) @@ -22,7 +62,7 @@ describe("When creating a Report with conflicts", () => { const report02 = { intent: "2222222222", engagementDate: moment() - .add(1, "day") + .add(2, "day") .hours(1) .minutes(10) .seconds(0) @@ -38,7 +78,7 @@ describe("When creating a Report with conflicts", () => { expect(CreateReport.intent.getValue()).to.equal(report01.intent) expect(CreateReport.engagementDate.getValue()).to.equal( - report01.engagementDate.format("DD-MM-YYYY HH:mm") + getFormattedDateInput(report01) ) expect(CreateReport.duration.getValue()).to.equal(report01.duration) const advisor01 = CreateReport.getPersonByName("CIV ERINSON, Erin") @@ -70,7 +110,7 @@ describe("When creating a Report with conflicts", () => { expect(CreateReport.intent.getValue()).to.equal(report02.intent) expect(CreateReport.engagementDate.getValue()).to.equal( - report02.engagementDate.format("DD-MM-YYYY HH:mm") + getFormattedDateInput(report02) ) expect(CreateReport.duration.getValue()).to.equal(report02.duration) const advisor01 = CreateReport.getPersonByName("CIV ERINSON, Erin") @@ -116,7 +156,7 @@ describe("When creating a Report with conflicts", () => { expect(ShowReport.intent).to.equal(report01.intent) expect(ShowReport.engagementDate).to.equal( - report01.engagementDate.format("dddd, D MMMM YYYY @ HH:mm") + getFormattedEngagementDate(report01) ) expect(ShowReport.reportConflictIcon.isExisting()).to.equal(true) @@ -157,7 +197,7 @@ describe("When creating a Report with conflicts", () => { expect(ShowReport.intent).to.equal(report02.intent) expect(ShowReport.engagementDate).to.equal( - report02.engagementDate.format("dddd, D MMMM YYYY @ HH:mm") + getFormattedEngagementDate(report02) ) expect(ShowReport.reportConflictIcon.isExisting()).to.equal(true) diff --git a/client/tests/webdriver/customFieldsSpecs/createReport.spec.js b/client/tests/webdriver/customFieldsSpecs/createReport.spec.js index 050c77a204..48301ad5cf 100644 --- a/client/tests/webdriver/customFieldsSpecs/createReport.spec.js +++ b/client/tests/webdriver/customFieldsSpecs/createReport.spec.js @@ -9,7 +9,7 @@ const INVALID_ENGAGEMENT_DURATION_1 = "123456" const INVALID_ENGAGEMENT_DURATION_2 = "-1" // positive sliced at 4th digit, negative should turn into 0 const VALID_ENGAGEMENT_DURATION_1 = "1234" -const VALID_ENGAGEMENT_DURATION_2 = "0" +const VALID_ENGAGEMENT_DURATION_2 = "" const PERSON = "EF 2.1" const PERSON_VALUE_1 = "HENDERSON, Henry" @@ -36,6 +36,10 @@ describe("Create report form page", () => { }) it("Should be able to prevent invalid duration values", () => { + // make it not an all day first to enable duration input + CreateReport.allDayCheckbox.click() + CreateReport.duration.waitForClickable() + CreateReport.duration.setValue(INVALID_ENGAGEMENT_DURATION_1) browser.waitUntil( () => { @@ -48,7 +52,13 @@ describe("Create report form page", () => { timeoutMsg: "Large positive duration value was not sliced " } ) - CreateReport.duration.setValue(INVALID_ENGAGEMENT_DURATION_2) + + // remove first value, otherwise appends the value + CreateReport.duration.setValue( + "\uE003".repeat(VALID_ENGAGEMENT_DURATION_1) + + INVALID_ENGAGEMENT_DURATION_2 + ) + browser.waitUntil( () => { return ( diff --git a/client/tests/webdriver/pages/createReport.page.js b/client/tests/webdriver/pages/createReport.page.js index abea110f4b..862a02c461 100644 --- a/client/tests/webdriver/pages/createReport.page.js +++ b/client/tests/webdriver/pages/createReport.page.js @@ -40,6 +40,10 @@ export class CreateReport extends Page { return browser.$(`div[id="fg-${id}"]`) } + get allDayCheckbox() { + return browser.$("#all-day-col label") + } + get engagementInformationTitle() { return browser.$('//span[text()="Engagement information"]') } diff --git a/client/tests/webdriver/pages/report/createReport.page.js b/client/tests/webdriver/pages/report/createReport.page.js index 222425dc0a..7651788b91 100644 --- a/client/tests/webdriver/pages/report/createReport.page.js +++ b/client/tests/webdriver/pages/report/createReport.page.js @@ -24,15 +24,20 @@ class CreateReport extends Page { return browser.$("#engagementDate") } - get tomorrow() { - const tomorrow = moment().add(1, "day").format("ddd MMM DD YYYY") - return browser.$(`div[aria-label="${tomorrow}"]`) + get datePickPopover() { + // check the today button + const today = moment().format("ddd MMM DD YYYY") + return browser.$(`div[aria-label="${today}"]`) } get duration() { return browser.$("#duration") } + get allDayCheckbox() { + return browser.$("#all-day-col label") + } + get reportPeople() { return browser.$("#reportPeople") } @@ -91,13 +96,17 @@ class CreateReport extends Page { this.intentHelpBlock.waitForExist({ reverse: true }) if (moment.isMoment(fields.engagementDate)) { + // remove all day as it would block duration adding + if (!this.duration.isClickable()) { + this.allDayCheckbox.click() + } this.engagementDate.waitForClickable() this.engagementDate.click() - this.tomorrow.waitForDisplayed() + this.datePickPopover.waitForDisplayed() browser.keys(fields.engagementDate.format("DD-MM-YYYY HH:mm")) this.title.click() - this.tomorrow.waitForExist({ reverse: true, timeout: 3000 }) + this.datePickPopover.waitForExist({ reverse: true, timeout: 3000 }) } if (fields.duration !== undefined) {