From 383723a128370641eceac26b58bbb3751b147f0e Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Fri, 6 Aug 2021 10:44:01 -0600 Subject: [PATCH] Implement Date ranges (#660) * initial commit * update to work around state issue * update comments * temp commit * add unit tests, cleanup * language extract, lint fix * update stored object to be same as state object * fixing merge issues * update dates to use locale * linter fixes, test cleanup * self review * add unit test, fixes * fix linter * remove dateFormat from calls * move DateRange to components * update translations * spacing fix * update tests, fix some hour inconsistencies * remove logging * more updates * more cleanup * update to disply mm/dd/yyyy when editing * update to disply mm/dd/yyyy when editing * input date use locale * update css to make selection round * update css to make begin/end round --- .vscode/settings.json | 4 +- webapp/i18n/en.json | 25 +- .../__snapshots__/dateRange.test.tsx.snap | 103 +++++++ .../properties/dateRange/dateRange.scss | 155 ++++++++++ .../properties/dateRange/dateRange.test.tsx | 245 ++++++++++++++++ .../properties/dateRange/dateRange.tsx | 266 ++++++++++++++++++ .../src/components/propertyValueElement.tsx | 4 +- webapp/src/octoUtils.tsx | 20 +- webapp/src/utils.test.ts | 15 + webapp/src/utils.ts | 9 +- webapp/src/widgets/editable.tsx | 5 +- webapp/src/widgets/editableDayPicker.scss | 6 +- 12 files changed, 827 insertions(+), 30 deletions(-) create mode 100644 webapp/src/components/properties/dateRange/__snapshots__/dateRange.test.tsx.snap create mode 100644 webapp/src/components/properties/dateRange/dateRange.scss create mode 100644 webapp/src/components/properties/dateRange/dateRange.test.tsx create mode 100644 webapp/src/components/properties/dateRange/dateRange.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index b3c9a5c9f..a143968c3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,8 +8,8 @@ "typescriptreact" ], "files.exclude": { - ".vscode": true, - "**/__snapshots__": true, + ".vscode": false, + "**/__snapshots__": false, "**/node_modules": true, "bin": true, "files": true, diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 10390a0b0..54421adfe 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -32,12 +32,13 @@ "ContentBlock.moveDown": "Move down", "ContentBlock.moveUp": "Move up", "ContentBlock.text": "text", - "DashboardPage.title": "Welcome to Focalboard (Beta)!", "DashboardPage.message": "Use Focalboard to create and track tasks for projects big and small using familiar kanban-boards, tables, and other views.", + "DashboardPage.title": "Welcome to Focalboard (Beta)!", "Dialog.closeDialog": "Close dialog", "EditableDayPicker.today": "Today", "EmptyCenterPanel.no-content": "Add or select a board from the sidebar to get started.", "EmptyCenterPanel.workspace": "This is the workspace for:", + "Error.websocket-closed": "Websocket connection closed, connection interrupted. If this persists, check your server or web proxy configuration.", "Filter.includes": "includes", "Filter.is-empty": "is empty", "Filter.is-not-empty": "is not empty", @@ -90,37 +91,22 @@ "Sidebar.add-board": "+ Add Board", "Sidebar.add-template": "New template", "Sidebar.changePassword": "Change password", - "Sidebar.chinese": "Traditional Chinese", - "Sidebar.dark-theme": "Dark theme", - "Sidebar.default-theme": "Default theme", "Sidebar.delete-board": "Delete board", "Sidebar.delete-template": "Delete", "Sidebar.duplicate-board": "Duplicate board", - "Sidebar.dutch": "Dutch", "Sidebar.edit-template": "Edit", "Sidebar.empty-board": "Empty board", - "Sidebar.english": "English", "Sidebar.export-archive": "Export archive", - "Sidebar.french": "French", - "Sidebar.german": "German", "Sidebar.import-archive": "Import archive", "Sidebar.invite-users": "Invite Users", - "Sidebar.japanese": "Japanese", - "Sidebar.light-theme": "Light theme", "Sidebar.logout": "Log out", "Sidebar.no-views-in-board": "No pages inside", - "Sidebar.occitan": "Occitan", "Sidebar.random-icons": "Random icons", - "Sidebar.russian": "Russian", "Sidebar.select-a-template": "Select a template", "Sidebar.set-language": "Set language", "Sidebar.set-theme": "Set theme", "Sidebar.settings": "Settings", - "Sidebar.simplified-chinese": "Simplified Chinese", - "Sidebar.spanish": "Spanish", - "Sidebar.system-theme": "System theme", "Sidebar.template-from-board": "New template from board", - "Sidebar.turkish": "Turkish", "Sidebar.untitled": "Untitled", "Sidebar.untitled-board": "(Untitled Board)", "Sidebar.untitled-view": "(Untitled View)", @@ -147,6 +133,7 @@ "ViewHeader.delete-template": "Delete", "ViewHeader.edit-template": "Edit", "ViewHeader.empty-card": "Empty card", + "ViewHeader.export-board-archive": "Export board archive", "ViewHeader.export-complete": "Export complete!", "ViewHeader.export-csv": "Export to CSV", "ViewHeader.export-failed": "Export failed!", @@ -160,11 +147,11 @@ "ViewHeader.share-board": "Share board", "ViewHeader.sort": "Sort", "ViewHeader.untitled": "Untitled", - "ViewTitle.hide-description": "Hide description", + "ViewTitle.hide-description": "hide description", "ViewTitle.pick-icon": "Pick icon", "ViewTitle.random-icon": "Random", "ViewTitle.remove-icon": "Remove icon", - "ViewTitle.show-description": "Show description", + "ViewTitle.show-description": "show description", "ViewTitle.untitled-board": "Untitled board", "Workspace.editing-board-template": "You're editing a board template", "default-properties.title": "Title", @@ -175,4 +162,4 @@ "login.register-button": "or create an account if you don't have one", "register.login-button": "or login if you already have an account", "register.signup-title": "Sign up for your account" -} +} \ No newline at end of file diff --git a/webapp/src/components/properties/dateRange/__snapshots__/dateRange.test.tsx.snap b/webapp/src/components/properties/dateRange/__snapshots__/dateRange.test.tsx.snap new file mode 100644 index 000000000..ef7dfb2c8 --- /dev/null +++ b/webapp/src/components/properties/dateRange/__snapshots__/dateRange.test.tsx.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/properties/dateRange cancel set via text input 1`] = ` +
+
+ +
+
+`; + +exports[`components/properties/dateRange handle clear 1`] = ` +
+
+ +
+
+`; + +exports[`components/properties/dateRange returns default correctly 1`] = ` +
+
+ +
+
+`; + +exports[`components/properties/dateRange returns local correctly - es local 1`] = ` +
+
+ +
+
+`; + +exports[`components/properties/dateRange set via text input 1`] = ` +
+
+ +
+
+`; + +exports[`components/properties/dateRange set via text input, es locale 1`] = ` +
+
+ +
+
+`; diff --git a/webapp/src/components/properties/dateRange/dateRange.scss b/webapp/src/components/properties/dateRange/dateRange.scss new file mode 100644 index 000000000..db4b352b9 --- /dev/null +++ b/webapp/src/components/properties/dateRange/dateRange.scss @@ -0,0 +1,155 @@ +.DateRange { + .octo-propertyvalue { + white-space: none; + } + + .inputContainer { + display: flex; + .Editable { + width: 50% !important; + } + } + + input { + cursor: text; + overflow: hidden; + text-overflow: ellipsis; + min-height: 24px; + width: 100%; + margin: 2px; + &.active { + min-width: 100px; + } + &::placeholder { + color: rgba(var(--body-color), 0.4); + opacity: 1; + } + &.error { + border: 1px solid var(--error-color); + border-radius: var(--default-rad); + } + } + + .Modal{ + position: absolute; + top: 0px; + left: 0px; + margin-bottom: 100px; + } + + .Button{ + width: 100%; + justify-content: left; + &:hover { + background-color: transparent; + } + } + .menu-option { + display: flex; + flex-direction: row; + align-items: center; + + font-size: 14px; + line-height: 24px; + font-weight: 400; + height: 32px; + padding: 4px 0px; + cursor: pointer; + + &:hover { + background: rgba(var(--button-bg), 0.08); + } + + > * { + margin-left: 6px; + } + + > *:first-child { + margin-left: 0; + } + + > .menu-name { + display: flex; + flex-grow: 1; + white-space: nowrap; + } + + > .SubmenuTriangleIcon { + fill: rgba(var(--body-color), 0.7); + } + + > .Icon { + opacity: 0.56; + width: 16px; + height: 16px; + line-height: 16px; + } + + > .IconButton .Icon { + margin-right: 0; + } + } + + .DayPicker { + } + + .DayPickerInput-Overlay { + background-color: rgba(var(--main-bg)); + box-shadow: 0 0 0 1px hsla(0, 0%, 0%, 0.1),0 4px 11px hsla(0, 0%, 0%, 0.1); + line-height: 100%; + } + + .DayPicker-Month { + margin: 0; + margin-top: 1em; + display: flex; + flex-direction: column; + } + .DayPicker-Body { + display: flex; + flex-direction: column; + } + .DayPicker-WeekdaysRow { + display: flex; + flex-direction: row; + } + .DayPicker-Weekday { + display: block; + width: 36px; + height: 36px; + } + .DayPicker-Week { + display: flex; + flex-direction: row; + } + .DayPicker-Day { + border-radius: unset; + display: block; + width: 36px; + height: 36px; + } + .DayPicker-Day--today { + color: #c74655; + } + .DayPicker-Day--start { + border-top-left-radius: 50% !important; + border-bottom-left-radius: 50% !important; + } + .DayPicker-Day--end { + border-top-right-radius: 50% !important; + border-bottom-right-radius: 50% !important; + } + .DayPicker-Day--selected:not(.DayPicker-Day--disabled):not(.DayPicker-Day--outside) { + background-color: rgb(var(--button-bg));; + color: rgba(var(--button-fg), 0.5); + } + .DayPicker-Day--selected:not(.DayPicker-Day--start):not(.DayPicker-Day--end):not(.DayPicker-Day--outside) { + color: rgb(var(--button-bg));; + background-color: rgba(var(--button-bg), 0.2); + } + + .DayPicker:not(.DayPicker--interactionDisabled) + .DayPicker-Day:not(.DayPicker-Day--disabled):not(.DayPicker-Day--selected):not(.DayPicker-Day--outside):hover { + background-color: rgba(var(--body-color), 0.2); + } +} \ No newline at end of file diff --git a/webapp/src/components/properties/dateRange/dateRange.test.tsx b/webapp/src/components/properties/dateRange/dateRange.test.tsx new file mode 100644 index 000000000..b019d39f6 --- /dev/null +++ b/webapp/src/components/properties/dateRange/dateRange.test.tsx @@ -0,0 +1,245 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react' +import {render} from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import '@testing-library/jest-dom' +import {IntlProvider} from 'react-intl' + +import DateRange from '../dateRange/dateRange' + +const wrapIntl = (children: any) => {children} + +// create Dates for specific days for this year. +const June15 = new Date(Date.UTC(new Date().getFullYear(), 5, 15, 12)) +const June15Local = new Date(new Date().getFullYear(), 5, 15, 12) +const June20 = new Date(Date.UTC(new Date().getFullYear(), 5, 20, 12)) + +describe('components/properties/dateRange', () => { + beforeEach(() => { + // Quick fix to disregard console error when unmounting a component + console.error = jest.fn() + document.execCommand = jest.fn() + }) + + test('returns default correctly', () => { + const component = wrapIntl( + , + ) + + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('returns local correctly - es local', () => { + const component = ( + + + + ) + + const {container, getByText} = render(component) + const input = getByText('15 de junio') + expect(input).not.toBeNull() + expect(container).toMatchSnapshot() + }) + + test('handles calendar click event', () => { + const callback = jest.fn() + const component = wrapIntl( + , + ) + + const date = new Date() + const fifteenth = Date.UTC(date.getFullYear(), date.getMonth(), 15, 12) + + const {getByText, getByTitle} = render(component) + const dayDisplay = getByText('Empty') + userEvent.click(dayDisplay) + + const day = getByText('15') + const modal = getByTitle('Close').children[0] + userEvent.click(day) + userEvent.click(modal) + + const rObject = {from: fifteenth} + expect(callback).toHaveBeenCalledWith(JSON.stringify(rObject)) + }) + + test('handles setting range', () => { + const callback = jest.fn() + const component = wrapIntl( + , + ) + + // open modal + const {getByText, getByTitle} = render(component) + const dayDisplay = getByText('Empty') + userEvent.click(dayDisplay) + + // select start date + const date = new Date() + const fifteenth = Date.UTC(date.getFullYear(), date.getMonth(), 15, 12) + const start = getByText('15') + userEvent.click(start) + + // create range + const endDate = getByText('End date') + userEvent.click(endDate) + + const twentieth = Date.UTC(date.getFullYear(), date.getMonth(), 20, 12) + + const end = getByText('20') + const modal = getByTitle('Close').children[0] + userEvent.click(end) + userEvent.click(modal) + + const rObject = {from: fifteenth, to: twentieth} + expect(callback).toHaveBeenCalledWith(JSON.stringify(rObject)) + }) + + test('handle clear', () => { + const callback = jest.fn() + const component = wrapIntl( + , + ) + + const {container, getByText, getByTitle} = render(component) + expect(container).toMatchSnapshot() + + // open modal + const dayDisplay = getByText('June 15') + userEvent.click(dayDisplay) + + const clear = getByText('Clear') + const modal = getByTitle('Close').children[0] + userEvent.click(clear) + userEvent.click(modal) + + expect(callback).toHaveBeenCalledWith('') + }) + + test('set via text input', () => { + const callback = jest.fn() + const component = wrapIntl( + , + ) + + const {container, getByRole, getByTitle, getByDisplayValue} = render(component) + expect(container).toMatchSnapshot() + + // open modal + const dayDisplay = getByRole('button', {name: 'June 15 -> June 20'}) + + userEvent.click(dayDisplay) + + const fromInput = getByDisplayValue('June 15') + const toInput = getByDisplayValue('June 20') + + userEvent.type(fromInput, '{selectall}{delay}07/15/2021{enter}') + userEvent.type(toInput, '{selectall}{delay}07/20/2021{enter}') + + const July15 = new Date(Date.UTC(2021, 6, 15, 12)) + const July20 = new Date(Date.UTC(2021, 6, 20, 12)) + const modal = getByTitle('Close').children[0] + + userEvent.click(modal) + + // {from: '2021-07-15', to: '2021-07-20'} + const retVal = '{"from":' + July15.getTime().toString() + ',"to":' + July20.getTime().toString() + '}' + expect(callback).toHaveBeenCalledWith(retVal) + }) + + test('set via text input, es locale', () => { + const callback = jest.fn() + + const component = ( + + + + ) + const {container, getByRole, getByTitle, getByDisplayValue} = render(component) + expect(container).toMatchSnapshot() + + // open modal + const dayDisplay = getByRole('button', {name: '15 de junio -> 20 de junio'}) + + userEvent.click(dayDisplay) + + const fromInput = getByDisplayValue('15 de junio') + const toInput = getByDisplayValue('20 de junio') + + userEvent.type(fromInput, '{selectall}15/07/2021{enter}') + userEvent.type(toInput, '{selectall}20/07/2021{enter}') + + const July15 = new Date(Date.UTC(2021, 6, 15, 12)) + const July20 = new Date(Date.UTC(2021, 6, 20, 12)) + const modal = getByTitle('Close').children[0] + + userEvent.click(modal) + + // {from: '2021-07-15', to: '2021-07-20'} + const retVal = '{"from":' + July15.getTime().toString() + ',"to":' + July20.getTime().toString() + '}' + expect(callback).toHaveBeenCalledWith(retVal) + }) + + test('cancel set via text input', () => { + const callback = jest.fn() + const component = wrapIntl( + , + ) + + const {container, getByRole, getByTitle, getByDisplayValue} = render(component) + expect(container).toMatchSnapshot() + + // open modal + const dayDisplay = getByRole('button', {name: 'June 15 -> June 20'}) + userEvent.click(dayDisplay) + + const fromInput = getByDisplayValue('June 15') + const toInput = getByDisplayValue('June 20') + userEvent.type(fromInput, '{selectall}07/15/2021{delay}{esc}') + userEvent.type(toInput, '{selectall}07/20/2021{delay}{esc}') + + const modal = getByTitle('Close').children[0] + userEvent.click(modal) + + // const retVal = {from: '2021-06-15', to: '2021-06-20'} + const retVal = '{"from":' + June15.getTime().toString() + ',"to":' + June20.getTime().toString() + '}' + expect(callback).toHaveBeenCalledWith(retVal) + }) +}) diff --git a/webapp/src/components/properties/dateRange/dateRange.tsx b/webapp/src/components/properties/dateRange/dateRange.tsx new file mode 100644 index 000000000..363edd406 --- /dev/null +++ b/webapp/src/components/properties/dateRange/dateRange.tsx @@ -0,0 +1,266 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useState} from 'react' +import {useIntl} from 'react-intl' +import {DateUtils} from 'react-day-picker' +import MomentLocaleUtils from 'react-day-picker/moment' +import DayPicker from 'react-day-picker/DayPicker' + +import moment from 'moment' + +import Editable from '../../../widgets/editable' +import SwitchOption from '../../../widgets/menu/switchOption' +import Button from '../../../widgets/buttons/button' + +import Modal from '../../../components/modal' +import ModalWrapper from '../../../components/modalWrapper' + +import 'react-day-picker/lib/style.css' +import './dateRange.scss' +import {Utils} from '../../../utils' + +type Props = { + className: string + value: string + onChange: (value: string) => void +} + +type DateProperty = { + from?: number + to?: number + includeTime?: boolean + timeZone?: string +} + +const loadedLocales: Record = {} + +function DateRange(props: Props): JSX.Element { + const {className, value, onChange} = props + const intl = useIntl() + const timeZoneOffset = new Date().getTimezoneOffset() * 60 * 1000 + + const getDisplayDate = (date: Date | null | undefined) => { + let displayDate = '' + if (date) { + displayDate = Utils.displayDate(date, intl) + } + return displayDate + } + + const createDatePropertyFromString = (initialValue: string) => { + let dateProperty: DateProperty = {} + if (initialValue) { + const singleDate = new Date(Number(initialValue)) + if (singleDate && DateUtils.isDate(singleDate)) { + dateProperty.from = singleDate.getTime() + } else { + try { + dateProperty = JSON.parse(initialValue) + } catch { + //Don't do anything, return empty dateProperty + } + } + } + return dateProperty + } + + const [dateProperty, setDateProperty] = useState(createDatePropertyFromString(value as string)) + const [showDialog, setShowDialog] = useState(false) + + // Keep dateProperty as UTC, + // dateFrom / dateTo will need converted to local time, to ensure date stays consistent + // dateFrom / dateTo will be used for input and calendar dates + const dateFrom = dateProperty.from ? new Date(dateProperty.from + (dateProperty.includeTime ? 0 : timeZoneOffset)) : undefined + const dateTo = dateProperty.to ? new Date(dateProperty.to + (dateProperty.includeTime ? 0 : timeZoneOffset)) : undefined + const [fromInput, setFromInput] = useState(getDisplayDate(dateFrom)) + const [toInput, setToInput] = useState(getDisplayDate(dateTo)) + + const isRange = dateTo !== undefined + + const locale = intl.locale.toLowerCase() + if (locale && locale !== 'en' && !loadedLocales[locale]) { + /* eslint-disable global-require */ + loadedLocales[locale] = require(`moment/locale/${locale}`) + /* eslint-disable global-require */ + } + + const handleDayClick = (day: Date) => { + const range : DateProperty = {} + if (isRange) { + const newRange = DateUtils.addDayToRange(day, {from: dateFrom, to: dateTo}) + range.from = newRange.from?.getTime() + range.to = newRange.to?.getTime() + } else { + range.from = day.getTime() + range.to = undefined + } + saveRangeValue(range) + } + + const onRangeClick = () => { + let range : DateProperty = { + from: dateFrom?.getTime(), + to: dateFrom?.getTime(), + } + if (isRange) { + range = ({ + from: dateFrom?.getTime(), + to: undefined, + }) + } + saveRangeValue(range) + } + + const onClear = () => { + saveRangeValue({}) + } + + const saveRangeValue = (range: DateProperty) => { + const rangeUTC = {...range} + if (rangeUTC.from) { + rangeUTC.from -= dateProperty.includeTime ? 0 : timeZoneOffset + } + if (rangeUTC.to) { + rangeUTC.to -= dateProperty.includeTime ? 0 : timeZoneOffset + } + + setDateProperty(rangeUTC) + setFromInput(getDisplayDate(range.from ? new Date(range.from) : undefined)) + setToInput(getDisplayDate(range.to ? new Date(range.to) : undefined)) + } + + let displayValue = '' + if (dateFrom) { + displayValue = getDisplayDate(dateFrom) + } + if (dateTo) { + displayValue += ' -> ' + getDisplayDate(dateTo) + } + + const onClose = () => { + // not actually setting here, + // but using to retreive the current state + setDateProperty((current) => { + if (current && current.from) { + onChange(JSON.stringify(current)) + } else { + onChange('') + } + return {...current} + }) + setShowDialog(false) + } + + return ( +
+ + + {showDialog && + + onClose()} + > +
+
+
+ { + if (dateFrom) { + return setFromInput(Utils.inputDate(dateFrom, intl)) + } + return undefined + }} + onChange={setFromInput} + onSave={() => { + const newDate = MomentLocaleUtils.parseDate(fromInput, 'L', intl.locale) + if (newDate && DateUtils.isDate(newDate)) { + newDate.setHours(12) + const range : DateProperty = { + from: newDate.getTime(), + to: dateTo?.getTime(), + } + saveRangeValue(range) + } else { + setFromInput(getDisplayDate(dateFrom)) + } + }} + onCancel={() => { + setFromInput(getDisplayDate(dateFrom)) + }} + /> + {dateTo && + { + if (dateTo) { + return setToInput(Utils.inputDate(dateTo, intl)) + } + return undefined + }} + onChange={setToInput} + onSave={() => { + const newDate = MomentLocaleUtils.parseDate(toInput, 'L', intl.locale) + if (newDate && DateUtils.isDate(newDate)) { + newDate.setHours(12) + const range : DateProperty = { + from: dateFrom?.getTime(), + to: newDate.getTime(), + } + saveRangeValue(range) + } else { + setToInput(getDisplayDate(dateTo)) + } + }} + onCancel={() => { + setToInput(getDisplayDate(dateTo)) + }} + /> + } +
+ +
+ +
+
+ +
+
+
+
+
+ } +
+ ) +} + +export default DateRange diff --git a/webapp/src/components/propertyValueElement.tsx b/webapp/src/components/propertyValueElement.tsx index 03388d00d..715599de1 100644 --- a/webapp/src/components/propertyValueElement.tsx +++ b/webapp/src/components/propertyValueElement.tsx @@ -16,7 +16,6 @@ import ValueSelector from '../widgets/valueSelector' import Label from '../widgets/label' -import EditableDayPicker from '../widgets/editableDayPicker' import Switch from '../widgets/switch' import IconButton from '../widgets/buttons/iconButton' import CloseIcon from '../widgets/icons/close' @@ -28,6 +27,7 @@ import LastModifiedBy from './properties/lastModifiedBy/lastModifiedBy' import LastModifiedAt from './properties/lastModifiedAt/lastModifiedAt' import CreatedAt from './properties/createdAt/createdAt' import CreatedBy from './properties/createdBy/createdBy' +import DateRange from './properties/dateRange/dateRange' type Props = { board: Board @@ -198,7 +198,7 @@ const PropertyValueElement = (props:Props): JSX.Element => { return
{displayValue}
} return ( - mutator.changePropertyValue(card, propertyTemplate.id, newValue)} diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index 3aa8a109a..bd07dc8c5 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -3,6 +3,8 @@ import {IntlShape} from 'react-intl' +import {DateUtils} from 'react-day-picker' + import {Block, createBlock} from './blocks/block' import {IPropertyTemplate, createBoard} from './blocks/board' import {BoardView, createBoardView} from './blocks/boardView' @@ -50,7 +52,23 @@ class OctoUtils { } case 'date': { if (propertyValue) { - displayValue = Utils.displayDate(new Date(parseInt(propertyValue as string, 10)), intl) + const singleDate = new Date(parseInt(propertyValue as string, 10)) + if (singleDate && DateUtils.isDate(singleDate)) { + displayValue = Utils.displayDate(new Date(parseInt(propertyValue as string, 10)), intl) + } else { + try { + const dateValue = JSON.parse(propertyValue as string) + if (dateValue.from) { + displayValue = Utils.displayDate(new Date(dateValue.from), intl) + } + if (dateValue.to) { + displayValue += ' -> ' + displayValue += Utils.displayDate(new Date(dateValue.to), intl) + } + } catch { + // do nothing + } + } } break } diff --git a/webapp/src/utils.test.ts b/webapp/src/utils.test.ts index b9ed7dfd5..ce316df9e 100644 --- a/webapp/src/utils.test.ts +++ b/webapp/src/utils.test.ts @@ -86,6 +86,21 @@ describe('utils', () => { }) }) + describe('input date', () => { + const currentYear = new Date().getFullYear() + const date = new Date(currentYear, 6, 9) + + it('should show mm/dd/yyyy for current year', () => { + const intl = createIntl({locale: 'en-us'}) + expect(Utils.inputDate(date, intl)).toBe(`07/09/${currentYear}`) + }) + + it('should show dd/mm/yyyy for current year, es local', () => { + const intl = createIntl({locale: 'es-es'}) + expect(Utils.inputDate(date, intl)).toBe(`09/07/${currentYear}`) + }) + }) + describe('display date and time', () => { const intl = createIntl({locale: 'en-us'}) diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index c17556af4..001efc2de 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -159,7 +159,6 @@ class Utils { } // Date and Time - private static yearOption(date: Date) { const isCurrentYear = date.getFullYear() === new Date().getFullYear() return isCurrentYear ? undefined : 'numeric' @@ -173,6 +172,14 @@ class Utils { }) } + static inputDate(date: Date, intl: IntlShape): string { + return intl.formatDate(date, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + } + static displayDateTime(date: Date, intl: IntlShape): string { return intl.formatDate(date, { year: Utils.yearOption(date), diff --git a/webapp/src/widgets/editable.tsx b/webapp/src/widgets/editable.tsx index d9fb73c70..e07486163 100644 --- a/webapp/src/widgets/editable.tsx +++ b/webapp/src/widgets/editable.tsx @@ -16,6 +16,7 @@ export type EditableProps = { validator?: (value: string) => boolean onCancel?: () => void onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void + onFocus?: () => void } export type Focusable = { @@ -33,7 +34,8 @@ export type ElementProps = { onBlur: () => void, onKeyDown: (e: React.KeyboardEvent) => void, readOnly?: boolean, - spellCheck?: boolean + spellCheck?: boolean, + onFocus?: () => void, } export function useEditable( @@ -109,6 +111,7 @@ export function useEditable( }, readOnly: readonly, spellCheck: props.spellCheck, + onFocus: props.onFocus, } } diff --git a/webapp/src/widgets/editableDayPicker.scss b/webapp/src/widgets/editableDayPicker.scss index 63b1c8207..fcbddcdde 100644 --- a/webapp/src/widgets/editableDayPicker.scss +++ b/webapp/src/widgets/editableDayPicker.scss @@ -7,7 +7,6 @@ text-overflow: ellipsis; border: 1px solid transparent; min-height: 24px; - &.active { min-width: 100px; } @@ -21,17 +20,16 @@ } } } - + .DayPickerInput-Overlay { background-color: rgba(var(--main-bg)); box-shadow: 0 0 0 1px hsla(0, 0%, 0%, 0.1),0 4px 11px hsla(0, 0%, 0%, 0.1); line-height: 100%; } - .DayPicker-Day--today { color: #c74655; } - + .DayPicker:not(.DayPicker--interactionDisabled) .DayPicker-Day:not(.DayPicker-Day--disabled):not(.DayPicker-Day--selected):not(.DayPicker-Day--outside):hover { background-color: rgba(var(--body-color), 0.2);