mirror of
https://github.com/mattermost/focalboard.git
synced 2024-11-24 08:22:29 +02:00
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
This commit is contained in:
parent
c734cfb8d1
commit
383723a128
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -8,8 +8,8 @@
|
||||
"typescriptreact"
|
||||
],
|
||||
"files.exclude": {
|
||||
".vscode": true,
|
||||
"**/__snapshots__": true,
|
||||
".vscode": false,
|
||||
"**/__snapshots__": false,
|
||||
"**/node_modules": true,
|
||||
"bin": true,
|
||||
"files": true,
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/properties/dateRange cancel set via text input 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="DateRange "
|
||||
>
|
||||
<button
|
||||
class="Button "
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
June 15 -> June 20
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/properties/dateRange handle clear 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="DateRange "
|
||||
>
|
||||
<button
|
||||
class="Button "
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
June 15
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/properties/dateRange returns default correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="DateRange "
|
||||
>
|
||||
<button
|
||||
class="Button "
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Empty
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/properties/dateRange returns local correctly - es local 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="DateRange "
|
||||
>
|
||||
<button
|
||||
class="Button "
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
15 de junio
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/properties/dateRange set via text input 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="DateRange "
|
||||
>
|
||||
<button
|
||||
class="Button "
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
June 15 -> June 20
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/properties/dateRange set via text input, es locale 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="DateRange "
|
||||
>
|
||||
<button
|
||||
class="Button "
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
15 de junio -> 20 de junio
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
155
webapp/src/components/properties/dateRange/dateRange.scss
Normal file
155
webapp/src/components/properties/dateRange/dateRange.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
245
webapp/src/components/properties/dateRange/dateRange.test.tsx
Normal file
245
webapp/src/components/properties/dateRange/dateRange.test.tsx
Normal file
@ -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) => <IntlProvider locale='en'>{children}</IntlProvider>
|
||||
|
||||
// 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(
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={''}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('returns local correctly - es local', () => {
|
||||
const component = (
|
||||
<IntlProvider locale='es'>
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={June15Local.getTime().toString()}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
)
|
||||
|
||||
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(
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={''}
|
||||
onChange={callback}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={''}
|
||||
onChange={callback}
|
||||
/>,
|
||||
)
|
||||
|
||||
// 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(
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={June15Local.getTime().toString()}
|
||||
onChange={callback}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
|
||||
onChange={callback}
|
||||
/>,
|
||||
)
|
||||
|
||||
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 = (
|
||||
<IntlProvider locale='es'>
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
|
||||
onChange={callback}
|
||||
/>
|
||||
</IntlProvider>
|
||||
)
|
||||
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(
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={'{"from": ' + June15.getTime().toString() + ',"to": ' + June20.getTime().toString() + '}'}
|
||||
onChange={callback}
|
||||
/>,
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
266
webapp/src/components/properties/dateRange/dateRange.tsx
Normal file
266
webapp/src/components/properties/dateRange/dateRange.tsx
Normal file
@ -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<string, any> = {}
|
||||
|
||||
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<DateProperty>(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<string>(getDisplayDate(dateFrom))
|
||||
const [toInput, setToInput] = useState<string>(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 (
|
||||
<div className={'DateRange '}>
|
||||
<Button
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
{displayValue || intl.formatMessage({id: 'DateRange.empty', defaultMessage: 'Empty'})}
|
||||
</Button>
|
||||
|
||||
{showDialog &&
|
||||
<ModalWrapper>
|
||||
<Modal
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div
|
||||
className={className + '-overlayWrapper'}
|
||||
>
|
||||
<div className={className + '-overlay'}>
|
||||
<div className={'inputContainer'}>
|
||||
<Editable
|
||||
value={fromInput}
|
||||
placeholderText={moment.localeData(locale).longDateFormat('L')}
|
||||
onFocus={() => {
|
||||
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 &&
|
||||
<Editable
|
||||
value={toInput}
|
||||
placeholderText={moment.localeData(locale).longDateFormat('L')}
|
||||
onFocus={() => {
|
||||
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))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<DayPicker
|
||||
onDayClick={handleDayClick}
|
||||
initialMonth={dateFrom || new Date()}
|
||||
showOutsideDays={true}
|
||||
locale={locale}
|
||||
localeUtils={MomentLocaleUtils}
|
||||
todayButton={intl.formatMessage({id: 'DateRange.today', defaultMessage: 'Today'})}
|
||||
selectedDays={[dateFrom, dateTo ? {from: dateFrom, to: dateTo} : {from: dateFrom, to: dateFrom}]}
|
||||
modifiers={dateTo ? {start: dateFrom, end: dateTo} : {start: dateFrom, end: dateFrom}}
|
||||
/>
|
||||
<hr/>
|
||||
<SwitchOption
|
||||
key={'EndDateOn'}
|
||||
id={'EndDateOn'}
|
||||
name={intl.formatMessage({id: 'DateRange.endDate', defaultMessage: 'End date'})}
|
||||
isOn={isRange}
|
||||
onClick={onRangeClick}
|
||||
/>
|
||||
<hr/>
|
||||
<div
|
||||
className='MenuOption menu-option'
|
||||
>
|
||||
<Button
|
||||
onClick={onClear}
|
||||
>
|
||||
{intl.formatMessage({id: 'DateRange.clear', defaultMessage: 'Clear'})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</ModalWrapper>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateRange
|
@ -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 <div className='octo-propertyvalue'>{displayValue}</div>
|
||||
}
|
||||
return (
|
||||
<EditableDayPicker
|
||||
<DateRange
|
||||
className='octo-propertyvalue'
|
||||
value={value as string}
|
||||
onChange={(newValue) => mutator.changePropertyValue(card, propertyTemplate.id, newValue)}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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'})
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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<ElementType>) => 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user