Implementation of Calendar MVP (#1741)
* initial commit * turn on featureflag * additional fixes * update for using FullCalendar * update to allow both calendars * fix dnd, remove log messages * fix lint, unit tests * dates should use themselves for timezone offset * fix for tests * remove react-big-calendar * remove react-big-calendar * fix for handling feature flags changing * clean up * remove unit test * update tests * fix tests * lint fixes * add creating event, fixes * linter fixes * clean up * add unit tests * fixes * update snapshots * update test * update snapshot * disable test for now, timezone changes labels * remove test to get to build * remove snapshot * feedback updates * use getConfig instead * linter fix * more linter * revert changes * some fixes for issues * fix for displaying new calendar * fix for displaying new calendar * add properties to cards * add properties to cards * read only implementation * i18-extract * implement unit tests * implement unit tests * fix test * remove log statements * remove feature flag from config * updated icons * Updating icons for calendar mvp * Revert "Updating icons for calendar mvp" This reverts commite16e715e8a
. * Revert "updated icons" This reverts commit120b7b0b96
. * update for code reviews * fix linter * more feedback updates * fix some styling * fix lint errors * Updating css * Updating calendar css * update for lint errors Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
This commit is contained in:
parent
941a47aead
commit
797d6bc04a
20 changed files with 6483 additions and 259 deletions
|
@ -73,7 +73,6 @@ func (p *Plugin) OnConfigurationChange() error { //nolint
|
|||
if p.wsPluginAdapter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
mmconfig := p.API.GetConfig()
|
||||
|
||||
// handle plugin configuration settings
|
||||
|
|
|
@ -7,8 +7,10 @@
|
|||
"BoardComponent.no-property": "No {property}",
|
||||
"BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.",
|
||||
"BoardComponent.show": "Show",
|
||||
"BoardPage.syncFailed": "Board may be deleted or access revoked.",
|
||||
"BoardPage.newVersion": "A new version of Boards is available, click here to reload.",
|
||||
"BoardPage.syncFailed": "Board may be deleted or access revoked.",
|
||||
"BoardsUnfurl.Remainder": "+{remainder} more",
|
||||
"BoardsUnfurl.Updated": "Updated {time}",
|
||||
"Calculations.Options.average.displayName": "Average",
|
||||
"Calculations.Options.average.label": "Average",
|
||||
"Calculations.Options.count.displayName": "Count",
|
||||
|
@ -43,8 +45,6 @@
|
|||
"Calculations.Options.range.label": "Range",
|
||||
"Calculations.Options.sum.displayName": "Sum",
|
||||
"Calculations.Options.sum.label": "Sum",
|
||||
"BoardsUnfurl.Remainder": "+{remainder} more",
|
||||
"BoardsUnfurl.Updated": "Updated {time}",
|
||||
"CardDetail.add-content": "Add content",
|
||||
"CardDetail.add-icon": "Add icon",
|
||||
"CardDetail.add-property": "+ Add a property",
|
||||
|
@ -206,12 +206,14 @@
|
|||
"View.DuplicateView": "Duplicate view",
|
||||
"View.Gallery": "Gallery",
|
||||
"View.NewBoardTitle": "Board view",
|
||||
"View.NewCalendarTitle": "Calendar View",
|
||||
"View.NewGalleryTitle": "Gallery view",
|
||||
"View.NewTableTitle": "Table view",
|
||||
"View.NewTemplateTitle": "Untitled Template",
|
||||
"View.Table": "Table",
|
||||
"ViewHeader.add-template": "New template",
|
||||
"ViewHeader.delete-template": "Delete",
|
||||
"ViewHeader.display-by": "Display by: {property}",
|
||||
"ViewHeader.edit-template": "Edit",
|
||||
"ViewHeader.empty-card": "Empty card",
|
||||
"ViewHeader.export-board-archive": "Export board archive",
|
||||
|
@ -239,6 +241,9 @@
|
|||
"WelcomePage.Explore.Button": "Explore",
|
||||
"WelcomePage.Heading": "Welcome To Boards",
|
||||
"Workspace.editing-board-template": "You're editing a board template.",
|
||||
"calendar.month": "Month",
|
||||
"calendar.today": "TODAY",
|
||||
"calendar.week": "Week",
|
||||
"default-properties.title": "Title",
|
||||
"error.no-workspace": "Your session may have expired or you may not have access to this workspace.",
|
||||
"error.relogin": "Log in again",
|
||||
|
@ -247,4 +252,4 @@
|
|||
"login.register-button": "or create an account if you don't have one",
|
||||
"register.login-button": "or log in if you already have an account",
|
||||
"register.signup-title": "Sign up for your account"
|
||||
}
|
||||
}
|
861
webapp/package-lock.json
generated
861
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -24,11 +24,16 @@
|
|||
"updatesnapshots": "jest --updateSnapshot"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^5.10.0",
|
||||
"@fullcalendar/daygrid": "^5.10.0",
|
||||
"@fullcalendar/interaction": "^5.10.0",
|
||||
"@fullcalendar/react": "^5.10.0",
|
||||
"@mattermost/compass-icons": "^0.1.10",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"color": "^4.0.0",
|
||||
"easymde": "^2.15.0",
|
||||
"emoji-mart": "^3.0.1",
|
||||
"fullcalendar": "^5.10.0",
|
||||
"imagemin-gifsicle": "^7.0.0",
|
||||
"imagemin-mozjpeg": "^9.0.0",
|
||||
"imagemin-optipng": "^8.0.0",
|
||||
|
|
|
@ -15,6 +15,7 @@ type KanbanCalculationFields = {
|
|||
type BoardViewFields = {
|
||||
viewType: IViewType
|
||||
groupById?: string
|
||||
dateDisplayPropertyId?: string
|
||||
sortOptions: ISortOption[]
|
||||
visiblePropertyIds: string[]
|
||||
visibleOptionIds: string[]
|
||||
|
@ -39,6 +40,7 @@ function createBoardView(block?: Block): BoardView {
|
|||
fields: {
|
||||
viewType: block?.fields.viewType || 'board',
|
||||
groupById: block?.fields.groupById,
|
||||
dateDisplayPropertyId: block?.fields.dateDisplayPropertyId,
|
||||
sortOptions: block?.fields.sortOptions?.map((o: ISortOption) => ({...o})) || [],
|
||||
visiblePropertyIds: block?.fields.visiblePropertyIds?.slice() || [],
|
||||
visibleOptionIds: block?.fields.visibleOptionIds?.slice() || [],
|
||||
|
|
File diff suppressed because it is too large
Load diff
88
webapp/src/components/calendar/fullCalendar.test.tsx
Normal file
88
webapp/src/components/calendar/fullCalendar.test.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
// 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 {TestBlockFactory} from '../../test/testBlockFactory'
|
||||
import '@testing-library/jest-dom'
|
||||
import {wrapIntl} from '../../testUtils'
|
||||
import {IPropertyTemplate} from '../../blocks/board'
|
||||
|
||||
import CalendarView from './fullCalendar'
|
||||
|
||||
jest.mock('../../mutator')
|
||||
|
||||
describe('components/calendar/toolbar', () => {
|
||||
const mockShow = jest.fn()
|
||||
const mockAdd = jest.fn()
|
||||
const dateDisplayProperty = {
|
||||
id: '12345',
|
||||
name: 'DateProperty',
|
||||
type: 'date',
|
||||
options: [],
|
||||
} as IPropertyTemplate
|
||||
const board = TestBlockFactory.createBoard()
|
||||
const view = TestBlockFactory.createBoardView(board)
|
||||
view.fields.viewType = 'calendar'
|
||||
view.fields.groupById = undefined
|
||||
const card = TestBlockFactory.createCard(board)
|
||||
const fifth = Date.UTC(2021, 9, 5, 12)
|
||||
const twentieth = Date.UTC(2021, 9, 20, 12)
|
||||
card.createAt = fifth
|
||||
const rObject = {from: twentieth}
|
||||
|
||||
test('return calendar, no date property', () => {
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
<CalendarView
|
||||
board={board}
|
||||
activeView={view}
|
||||
cards={[card]}
|
||||
readonly={false}
|
||||
showCard={mockShow}
|
||||
addCard={mockAdd}
|
||||
initialDate={new Date(fifth)}
|
||||
/>,
|
||||
),
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
test('return calendar, with date property not set', () => {
|
||||
board.fields.cardProperties.push(dateDisplayProperty)
|
||||
card.fields.properties['12345'] = JSON.stringify(rObject)
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
<CalendarView
|
||||
board={board}
|
||||
activeView={view}
|
||||
cards={[card]}
|
||||
readonly={false}
|
||||
showCard={mockShow}
|
||||
addCard={mockAdd}
|
||||
initialDate={new Date(fifth)}
|
||||
/>,
|
||||
),
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('return calendar, with date property set', () => {
|
||||
board.fields.cardProperties.push(dateDisplayProperty)
|
||||
card.fields.properties['12345'] = JSON.stringify(rObject)
|
||||
const {container} = render(
|
||||
wrapIntl(
|
||||
<CalendarView
|
||||
board={board}
|
||||
activeView={view}
|
||||
readonly={false}
|
||||
dateDisplayProperty={dateDisplayProperty}
|
||||
cards={[card]}
|
||||
showCard={mockShow}
|
||||
addCard={mockAdd}
|
||||
initialDate={new Date(fifth)}
|
||||
/>,
|
||||
),
|
||||
)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
209
webapp/src/components/calendar/fullCalendar.tsx
Normal file
209
webapp/src/components/calendar/fullCalendar.tsx
Normal file
|
@ -0,0 +1,209 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react'
|
||||
import {useIntl} from 'react-intl'
|
||||
|
||||
import FullCalendar, {EventClickArg, EventChangeArg, EventInput, EventContentArg} from '@fullcalendar/react'
|
||||
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
|
||||
import mutator from '../../mutator'
|
||||
|
||||
import {Board, IPropertyTemplate} from '../../blocks/board'
|
||||
import {BoardView} from '../../blocks/boardView'
|
||||
import {Card} from '../../blocks/card'
|
||||
import {DateProperty, createDatePropertyFromString} from '../properties/dateRange/dateRange'
|
||||
import Tooltip from '../../widgets/tooltip'
|
||||
import PropertyValueElement from '../propertyValueElement'
|
||||
|
||||
import './fullcalendar.scss'
|
||||
|
||||
const oneDay = 60 * 60 * 24 * 1000
|
||||
|
||||
type Props = {
|
||||
board: Board
|
||||
cards: Card[]
|
||||
activeView: BoardView
|
||||
readonly: boolean
|
||||
initialDate?: Date
|
||||
dateDisplayProperty?: IPropertyTemplate
|
||||
showCard: (cardId: string) => void
|
||||
addCard: (properties: Record<string, string>) => void
|
||||
}
|
||||
|
||||
function createDatePropertyFromCalendarDates(start: Date, end: Date) : DateProperty {
|
||||
// save as noon local, expected from the date picker
|
||||
start.setHours(12)
|
||||
const dateFrom = start.getTime() - timeZoneOffset(start.getTime())
|
||||
end.setHours(12)
|
||||
const dateTo = end.getTime() - timeZoneOffset(end.getTime()) - oneDay // subtract one day. Calendar is date exclusive
|
||||
|
||||
const dateProperty : DateProperty = {from: dateFrom}
|
||||
if (dateTo !== dateFrom) {
|
||||
dateProperty.to = dateTo
|
||||
}
|
||||
return dateProperty
|
||||
}
|
||||
|
||||
const timeZoneOffset = (date: number): number => {
|
||||
return new Date(date).getTimezoneOffset() * 60 * 1000
|
||||
}
|
||||
|
||||
const CalendarFullView = (props: Props): JSX.Element|null => {
|
||||
const intl = useIntl()
|
||||
const {board, cards, activeView, dateDisplayProperty, readonly} = props
|
||||
const isSelectable = !readonly
|
||||
|
||||
const visiblePropertyTemplates = useMemo(() => (
|
||||
board.fields.cardProperties.filter((template: IPropertyTemplate) => activeView.fields.visiblePropertyIds.includes(template.id))
|
||||
), [board.fields.cardProperties, activeView.fields.visiblePropertyIds])
|
||||
|
||||
let {initialDate} = props
|
||||
if (!initialDate) {
|
||||
initialDate = new Date()
|
||||
}
|
||||
|
||||
const isEditable = useCallback(() : boolean => {
|
||||
if (readonly || !dateDisplayProperty || (dateDisplayProperty.type === 'createdTime' || dateDisplayProperty.type === 'updatedTime')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [readonly, dateDisplayProperty])
|
||||
|
||||
const myEventsList = useMemo(() => (
|
||||
cards.flatMap((card): EventInput[] => {
|
||||
let dateFrom = new Date(card.createAt || 0)
|
||||
let dateTo = new Date(card.createAt || 0)
|
||||
if (dateDisplayProperty && dateDisplayProperty?.type === 'updatedTime') {
|
||||
dateFrom = new Date(card.updateAt || 0)
|
||||
dateTo = new Date(card.updateAt || 0)
|
||||
} else if (dateDisplayProperty && dateDisplayProperty?.type !== 'createdTime') {
|
||||
const dateProperty = createDatePropertyFromString(card.fields.properties[dateDisplayProperty.id || ''] as string)
|
||||
if (!dateProperty.from) {
|
||||
return []
|
||||
}
|
||||
|
||||
// date properties are stored as 12 pm UTC, convert to 12 am (00) UTC for calendar
|
||||
dateFrom = dateProperty.from ? new Date(dateProperty.from + (dateProperty.includeTime ? 0 : timeZoneOffset(dateProperty.from))) : new Date()
|
||||
dateFrom.setHours(0, 0, 0, 0)
|
||||
const dateToNumber = dateProperty.to ? dateProperty.to + (dateProperty.includeTime ? 0 : timeZoneOffset(dateProperty.to)) : dateFrom.getTime()
|
||||
dateTo = new Date(dateToNumber + oneDay) // Add one day.
|
||||
dateTo.setHours(0, 0, 0, 0)
|
||||
}
|
||||
return [{
|
||||
id: card.id,
|
||||
title: card.title,
|
||||
extendedProps: {icon: card.fields.icon},
|
||||
properties: card.fields.properties,
|
||||
allDay: true,
|
||||
start: dateFrom,
|
||||
end: dateTo,
|
||||
}]
|
||||
})
|
||||
), [cards, dateDisplayProperty])
|
||||
|
||||
const renderEventContent = (eventProps: EventContentArg): JSX.Element|null => {
|
||||
const {event} = eventProps
|
||||
return (
|
||||
<div>
|
||||
<div className='octo-icontitle'>
|
||||
{ event.extendedProps.icon ? <div className='octo-icon'>{event.extendedProps.icon}</div> : undefined }
|
||||
<div
|
||||
className='fc-event-title'
|
||||
key='__title'
|
||||
>{event.title || intl.formatMessage({id: 'KanbanCard.untitled', defaultMessage: 'Untitled'})}</div>
|
||||
</div>
|
||||
{visiblePropertyTemplates.map((template) => (
|
||||
<Tooltip
|
||||
key={template.id}
|
||||
title={template.name}
|
||||
>
|
||||
<PropertyValueElement
|
||||
board={board}
|
||||
readOnly={true}
|
||||
card={cards.find((o) => o.id === event.id) || cards[0]}
|
||||
contents={[]}
|
||||
comments={[]}
|
||||
propertyTemplate={template}
|
||||
showEmptyPlaceholder={false}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const eventClick = useCallback((eventProps: EventClickArg) => {
|
||||
const {event} = eventProps
|
||||
props.showCard(event.id)
|
||||
}, [props.showCard])
|
||||
|
||||
const eventChange = useCallback((eventProps: EventChangeArg) => {
|
||||
const {event} = eventProps
|
||||
if (!event.start) {
|
||||
return
|
||||
}
|
||||
if (!event.end) {
|
||||
return
|
||||
}
|
||||
|
||||
const startDate = new Date(event.start.getTime())
|
||||
const endDate = new Date(event.end.getTime())
|
||||
const dateProperty = createDatePropertyFromCalendarDates(startDate, endDate)
|
||||
const card = cards.find((o) => o.id === event.id)
|
||||
if (card && dateDisplayProperty) {
|
||||
mutator.changePropertyValue(card, dateDisplayProperty.id, JSON.stringify(dateProperty))
|
||||
}
|
||||
}, [cards, dateDisplayProperty])
|
||||
|
||||
const onNewEvent = useCallback((args: {start: Date, end: Date}) => {
|
||||
const dateProperty = createDatePropertyFromCalendarDates(args.start, args.end)
|
||||
|
||||
const properties: Record<string, string> = {}
|
||||
if (dateDisplayProperty) {
|
||||
properties[dateDisplayProperty.id] = JSON.stringify(dateProperty)
|
||||
}
|
||||
|
||||
props.addCard(properties)
|
||||
}, [props.addCard, dateDisplayProperty])
|
||||
|
||||
const toolbar = useMemo(() => ({
|
||||
left: 'title',
|
||||
center: '',
|
||||
right: 'dayGridWeek dayGridMonth prev,today,next',
|
||||
}), [])
|
||||
|
||||
const buttonText = useMemo(() => ({
|
||||
today: intl.formatMessage({id: 'calendar.today', defaultMessage: 'TODAY'}),
|
||||
month: intl.formatMessage({id: 'calendar.month', defaultMessage: 'Month'}),
|
||||
week: intl.formatMessage({id: 'calendar.week', defaultMessage: 'Week'}),
|
||||
}), [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='CalendarContainer'
|
||||
>
|
||||
<FullCalendar
|
||||
dayMaxEventRows={5}
|
||||
initialDate={initialDate}
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView='dayGridMonth'
|
||||
events={myEventsList}
|
||||
editable={isEditable()}
|
||||
eventResizableFromStart={isEditable()}
|
||||
headerToolbar={toolbar}
|
||||
buttonText={buttonText}
|
||||
eventClick={eventClick}
|
||||
eventContent={renderEventContent}
|
||||
eventChange={eventChange}
|
||||
selectable={isSelectable}
|
||||
selectMirror={true}
|
||||
select={onNewEvent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CalendarFullView
|
159
webapp/src/components/calendar/fullcalendar.scss
Normal file
159
webapp/src/components/calendar/fullcalendar.scss
Normal file
|
@ -0,0 +1,159 @@
|
|||
.CalendarContainer {
|
||||
margin-right: 80px;
|
||||
margin-bottom: 10px;
|
||||
overflow: auto;
|
||||
|
||||
.octo-tooltip {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.octo-propertyvalue {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
input[value=''] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Label.empty {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.octo-icontitle {
|
||||
flex: 1 1 auto;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
margin: 4px 0;
|
||||
|
||||
.octo-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.fc {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar.fc-header-toolbar {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
|
||||
.fc-button {
|
||||
background: rgba(var(--button-bg-rgb), 0.08);
|
||||
border-color: transparent;
|
||||
border-radius: 4px;
|
||||
color: rgb(var(--button-bg-rgb));
|
||||
margin: 2px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.fc-button-active {
|
||||
background-color: rgb(var(--button-bg-rgb));
|
||||
color: rgb(var(--button-color-rgb));
|
||||
}
|
||||
}
|
||||
|
||||
.fc-today-button,
|
||||
.fc-prev-button,
|
||||
.fc-next-button {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.fc-prev-button,
|
||||
.fc-next-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: rgb(var(--center-channel-color-rgb), 0.56);
|
||||
|
||||
&:hover {
|
||||
background: rgb(var(--center-channel-color-rgb), 0.08);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
color: rgb(var(--button-bg-rgb), 1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fc-event {
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
border-color: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
background-color: rgb(var(--center-channel-bg-rgb));
|
||||
box-shadow: var(--elevation-1);
|
||||
margin: 0 8px 10px;
|
||||
overflow: hidden;
|
||||
padding: 4px 6px;
|
||||
|
||||
&:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.fc-event-main {
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
}
|
||||
|
||||
.fc-event-title {
|
||||
font-size: 14px;
|
||||
overflow: unset;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.fc-day a {
|
||||
color: rgb(var(--center-channel-color-rgb));
|
||||
}
|
||||
|
||||
.fc-day-sat,
|
||||
.fc-day-sun {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.fc-day-today {
|
||||
background: transparent;
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
color: rgba(var(--button-color-rgb));
|
||||
background-color: rgba(var(--button-bg-rgb), 1);
|
||||
}
|
||||
}
|
||||
|
||||
.fc-daygrid-day-number {
|
||||
font-weight: 600;
|
||||
margin: 4px 4px 0 0;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
|
@ -6,6 +6,8 @@ import {injectIntl, IntlShape} from 'react-intl'
|
|||
import {connect} from 'react-redux'
|
||||
import Hotkeys from 'react-hot-keys'
|
||||
|
||||
import {ClientConfig} from '../config/clientConfig'
|
||||
|
||||
import {Block} from '../blocks/block'
|
||||
import {BlockIcons} from '../blockIcons'
|
||||
import {Card, createCard} from '../blocks/card'
|
||||
|
@ -28,15 +30,21 @@ import TopBar from './topBar'
|
|||
import ViewHeader from './viewHeader/viewHeader'
|
||||
import ViewTitle from './viewTitle'
|
||||
import Kanban from './kanban/kanban'
|
||||
|
||||
import Table from './table/table'
|
||||
|
||||
import CalendarFullView from './calendar/fullCalendar'
|
||||
|
||||
import Gallery from './gallery/gallery'
|
||||
|
||||
type Props = {
|
||||
clientConfig?: ClientConfig
|
||||
board: Board
|
||||
cards: Card[]
|
||||
activeView: BoardView
|
||||
views: BoardView[]
|
||||
groupByProperty?: IPropertyTemplate
|
||||
dateDisplayProperty?: IPropertyTemplate
|
||||
intl: IntlShape
|
||||
readonly: boolean
|
||||
addCard: (card: Card) => void
|
||||
|
@ -148,6 +156,7 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
cards={this.props.cards}
|
||||
views={this.props.views}
|
||||
groupByProperty={this.props.groupByProperty}
|
||||
dateDisplayProperty={this.props.dateDisplayProperty}
|
||||
addCard={() => this.addCard('', true)}
|
||||
addCardFromTemplate={this.addCardFromTemplate}
|
||||
addCardTemplate={this.addCardTemplate}
|
||||
|
@ -171,7 +180,6 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
addCard={this.addCard}
|
||||
showCard={this.showCard}
|
||||
/>}
|
||||
|
||||
{activeView.fields.viewType === 'table' &&
|
||||
<Table
|
||||
board={this.props.board}
|
||||
|
@ -187,6 +195,18 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
addCard={this.addCard}
|
||||
onCardClicked={this.cardClicked}
|
||||
/>}
|
||||
{activeView.fields.viewType === 'calendar' && this.props.clientConfig?.featureFlags.CalendarView &&
|
||||
<CalendarFullView
|
||||
board={this.props.board}
|
||||
cards={this.props.cards}
|
||||
activeView={this.props.activeView}
|
||||
readonly={this.props.readonly}
|
||||
dateDisplayProperty={this.props.dateDisplayProperty}
|
||||
showCard={this.showCard}
|
||||
addCard={(properties: Record<string, string>) => {
|
||||
this.addCard('', true, properties)
|
||||
}}
|
||||
/>}
|
||||
|
||||
{activeView.fields.viewType === 'gallery' &&
|
||||
<Gallery
|
||||
|
@ -198,7 +218,6 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
selectedCardIds={this.state.selectedCardIds}
|
||||
addCard={(show) => this.addCard('', show)}
|
||||
/>}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -232,7 +251,7 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
})
|
||||
}
|
||||
|
||||
addCard = async (groupByOptionId?: string, show = false): Promise<void> => {
|
||||
addCard = async (groupByOptionId?: string, show = false, properties: Record<string, string> = {}): Promise<void> => {
|
||||
const {activeView, board, groupByProperty} = this.props
|
||||
|
||||
const card = createCard()
|
||||
|
@ -249,7 +268,7 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
delete propertiesThatMeetFilters[groupByProperty.id]
|
||||
}
|
||||
}
|
||||
card.fields.properties = {...card.fields.properties, ...propertiesThatMeetFilters}
|
||||
card.fields.properties = {...card.fields.properties, ...properties, ...propertiesThatMeetFilters}
|
||||
if (!card.fields.icon && UserSettings.prefillRandomIcons) {
|
||||
card.fields.icon = BlockIcons.shared.randomIcon()
|
||||
}
|
||||
|
|
|
@ -33,6 +33,23 @@ export type DateProperty = {
|
|||
timeZone?: string
|
||||
}
|
||||
|
||||
export function createDatePropertyFromString(initialValue: string) : DateProperty {
|
||||
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 loadedLocales: Record<string, moment.Locale> = {}
|
||||
|
||||
function DateRange(props: Props): JSX.Element {
|
||||
|
@ -51,23 +68,6 @@ function DateRange(props: Props): JSX.Element {
|
|||
return new Date(date).getTimezoneOffset() * 60 * 1000
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import mutator from '../../mutator'
|
|||
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'
|
||||
import IconButton from '../../widgets/buttons/iconButton'
|
||||
import BoardIcon from '../../widgets/icons/board'
|
||||
import CalendarIcon from '../../widgets/icons/calendar'
|
||||
import DeleteIcon from '../../widgets/icons/delete'
|
||||
import DisclosureTriangle from '../../widgets/icons/disclosureTriangle'
|
||||
import DuplicateIcon from '../../widgets/icons/duplicate'
|
||||
|
@ -63,6 +64,7 @@ const SidebarBoardItem = React.memo((props: Props) => {
|
|||
case 'board': return <BoardIcon/>
|
||||
case 'table': return <TableIcon/>
|
||||
case 'gallery': return <GalleryIcon/>
|
||||
case 'calendar': return <CalendarIcon/>
|
||||
default: return <div/>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import ModalWrapper from '../modalWrapper'
|
|||
import NewCardButton from './newCardButton'
|
||||
import ViewHeaderPropertiesMenu from './viewHeaderPropertiesMenu'
|
||||
import ViewHeaderGroupByMenu from './viewHeaderGroupByMenu'
|
||||
import ViewHeaderDisplayByMenu from './viewHeaderDisplayByMenu'
|
||||
import ViewHeaderSortMenu from './viewHeaderSortMenu'
|
||||
import ViewHeaderActionsMenu from './viewHeaderActionsMenu'
|
||||
import ViewHeaderSearch from './viewHeaderSearch'
|
||||
|
@ -38,14 +39,17 @@ type Props = {
|
|||
editCardTemplate: (cardTemplateId: string) => void
|
||||
readonly: boolean
|
||||
showShared: boolean
|
||||
dateDisplayProperty?: IPropertyTemplate
|
||||
}
|
||||
|
||||
const ViewHeader = React.memo((props: Props) => {
|
||||
const [showFilter, setShowFilter] = useState(false)
|
||||
|
||||
const {board, activeView, views, groupByProperty, cards, showShared} = props
|
||||
const {board, activeView, views, groupByProperty, cards, showShared, dateDisplayProperty} = props
|
||||
|
||||
const withGroupBy = activeView.fields.viewType === 'board' || activeView.fields.viewType === 'table'
|
||||
const withDisplayBy = activeView.fields.viewType === 'calendar'
|
||||
const withSortBy = activeView.fields.viewType !== 'calendar'
|
||||
|
||||
const [viewTitle, setViewTitle] = useState(activeView.title)
|
||||
|
||||
|
@ -102,6 +106,15 @@ const ViewHeader = React.memo((props: Props) => {
|
|||
groupByPropertyName={groupByProperty?.name}
|
||||
/>}
|
||||
|
||||
{/* Display by */}
|
||||
|
||||
{withDisplayBy &&
|
||||
<ViewHeaderDisplayByMenu
|
||||
properties={board.fields.cardProperties}
|
||||
activeView={activeView}
|
||||
dateDisplayPropertyName={dateDisplayProperty?.name}
|
||||
/>}
|
||||
|
||||
{/* Filter */}
|
||||
|
||||
<ModalWrapper>
|
||||
|
@ -124,11 +137,13 @@ const ViewHeader = React.memo((props: Props) => {
|
|||
|
||||
{/* Sort */}
|
||||
|
||||
<ViewHeaderSortMenu
|
||||
properties={board.fields.cardProperties}
|
||||
activeView={activeView}
|
||||
orderedCards={cards}
|
||||
/>
|
||||
{withSortBy &&
|
||||
<ViewHeaderSortMenu
|
||||
properties={board.fields.cardProperties}
|
||||
activeView={activeView}
|
||||
orderedCards={cards}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
|
|
79
webapp/src/components/viewHeader/viewHeaderDisplayByMenu.tsx
Normal file
79
webapp/src/components/viewHeader/viewHeaderDisplayByMenu.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
import {FormattedMessage, useIntl} from 'react-intl'
|
||||
|
||||
import {IPropertyTemplate} from '../../blocks/board'
|
||||
import {BoardView} from '../../blocks/boardView'
|
||||
import mutator from '../../mutator'
|
||||
import Button from '../../widgets/buttons/button'
|
||||
import Menu from '../../widgets/menu'
|
||||
import MenuWrapper from '../../widgets/menuWrapper'
|
||||
import CheckIcon from '../../widgets/icons/check'
|
||||
import {typeDisplayName} from '../../widgets/propertyMenu'
|
||||
|
||||
type Props = {
|
||||
properties: readonly IPropertyTemplate[]
|
||||
activeView: BoardView
|
||||
dateDisplayPropertyName?: string
|
||||
}
|
||||
|
||||
const ViewHeaderDisplayByMenu = React.memo((props: Props) => {
|
||||
const {properties, activeView, dateDisplayPropertyName} = props
|
||||
const intl = useIntl()
|
||||
|
||||
const createdDateName = typeDisplayName(intl, 'createdTime')
|
||||
|
||||
const getDateProperties = () : IPropertyTemplate[] => {
|
||||
return properties?.filter((o: IPropertyTemplate) => o.type === 'date' || o.type === 'createdTime' || o.type === 'updatedTime')
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuWrapper>
|
||||
<Button>
|
||||
<FormattedMessage
|
||||
id='ViewHeader.display-by'
|
||||
defaultMessage='Display by: {property}'
|
||||
values={{
|
||||
property: (
|
||||
<span
|
||||
style={{color: 'rgb(var(--center-channel-color-rgb))'}}
|
||||
id='displayByLabel'
|
||||
>
|
||||
{dateDisplayPropertyName || createdDateName}
|
||||
</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Menu>
|
||||
{getDateProperties().length > 0 && getDateProperties().map((date: IPropertyTemplate) => (
|
||||
<Menu.Text
|
||||
key={date.id}
|
||||
id={date.id}
|
||||
name={date.name}
|
||||
rightIcon={activeView.fields.dateDisplayPropertyId === date.id ? <CheckIcon/> : undefined}
|
||||
onClick={(id) => {
|
||||
if (activeView.fields.dateDisplayPropertyId === id) {
|
||||
return
|
||||
}
|
||||
mutator.changeViewDateDisplayPropertyId(activeView.id, activeView.fields.dateDisplayPropertyId, id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{getDateProperties().length === 0 &&
|
||||
<Menu.Text
|
||||
key={'createdDate'}
|
||||
id={'createdDate'}
|
||||
name={createdDateName}
|
||||
rightIcon={<CheckIcon/>}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
export default ViewHeaderDisplayByMenu
|
|
@ -55,6 +55,7 @@ describe('/components/viewMenu', () => {
|
|||
},
|
||||
current: 'boardView',
|
||||
},
|
||||
clientConfig: {},
|
||||
}
|
||||
|
||||
const history = createMemoryHistory()
|
||||
|
|
|
@ -19,6 +19,8 @@ import DuplicateIcon from '../widgets/icons/duplicate'
|
|||
import GalleryIcon from '../widgets/icons/gallery'
|
||||
import TableIcon from '../widgets/icons/table'
|
||||
import Menu from '../widgets/menu'
|
||||
import {useAppSelector} from '../store/hooks'
|
||||
import {getClientConfig} from '../store/clientConfig'
|
||||
|
||||
type Props = {
|
||||
board: Board,
|
||||
|
@ -31,6 +33,7 @@ type Props = {
|
|||
const ViewMenu = React.memo((props: Props) => {
|
||||
const history = useHistory()
|
||||
const match = useRouteMatch()
|
||||
const clientConfig = useAppSelector(getClientConfig)
|
||||
|
||||
const showView = useCallback((viewId) => {
|
||||
let newPath = generatePath(match.path, {...match.params, viewId: viewId || ''})
|
||||
|
@ -169,6 +172,37 @@ const ViewMenu = React.memo((props: Props) => {
|
|||
})
|
||||
}, [props.board, props.activeView, props.intl, showView])
|
||||
|
||||
const handleAddViewCalendar = useCallback(() => {
|
||||
const {board, activeView, intl} = props
|
||||
|
||||
Utils.log('addview-calendar')
|
||||
const view = createBoardView()
|
||||
view.title = intl.formatMessage({id: 'View.NewCalendarTitle', defaultMessage: 'Calendar View'})
|
||||
view.fields.viewType = 'calendar'
|
||||
view.parentId = board.id
|
||||
view.rootId = board.rootId
|
||||
view.fields.visiblePropertyIds = [Constants.titleColumnId]
|
||||
|
||||
const oldViewId = activeView.id
|
||||
|
||||
// Find first date property
|
||||
view.fields.dateDisplayPropertyId = board.fields.cardProperties.find((o: IPropertyTemplate) => o.type === 'date')?.id
|
||||
|
||||
mutator.insertBlock(
|
||||
view,
|
||||
'add view',
|
||||
async (block: Block) => {
|
||||
// This delay is needed because WSClient has a default 100 ms notification delay before updates
|
||||
setTimeout(() => {
|
||||
Utils.log(`showView: ${block.id}`)
|
||||
showView(block.id)
|
||||
}, 120)
|
||||
},
|
||||
async () => {
|
||||
showView(oldViewId)
|
||||
})
|
||||
}, [props.board, props.activeView, props.intl, showView])
|
||||
|
||||
const {views, intl} = props
|
||||
|
||||
const duplicateViewText = intl.formatMessage({
|
||||
|
@ -257,6 +291,14 @@ const ViewMenu = React.memo((props: Props) => {
|
|||
icon={<GalleryIcon/>}
|
||||
onClick={handleAddViewGallery}
|
||||
/>
|
||||
{clientConfig?.featureFlags.CalendarView &&
|
||||
<Menu.Text
|
||||
id='calendar'
|
||||
name='Calendar'
|
||||
icon={<CalendarIcon/>}
|
||||
onClick={handleAddViewCalendar}
|
||||
/>
|
||||
}
|
||||
</Menu.SubMenu>
|
||||
}
|
||||
</Menu>
|
||||
|
|
|
@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl'
|
|||
|
||||
import {getCurrentBoard} from '../store/boards'
|
||||
import {getCurrentViewCardsSortedFilteredAndGrouped} from '../store/cards'
|
||||
import {getView, getCurrentBoardViews, getCurrentViewGroupBy, getCurrentView} from '../store/views'
|
||||
import {getView, getCurrentBoardViews, getCurrentViewGroupBy, getCurrentView, getCurrentViewDisplayBy} from '../store/views'
|
||||
import {useAppSelector, useAppDispatch} from '../store/hooks'
|
||||
|
||||
import {getClientConfig, setClientConfig} from '../store/clientConfig'
|
||||
|
@ -32,6 +32,7 @@ function CenterContent(props: Props) {
|
|||
const activeView = useAppSelector(getView(match.params.viewId))
|
||||
const views = useAppSelector(getCurrentBoardViews)
|
||||
const groupByProperty = useAppSelector(getCurrentViewGroupBy)
|
||||
const dateDisplayProperty = useAppSelector(getCurrentViewDisplayBy)
|
||||
const clientConfig = useAppSelector(getClientConfig)
|
||||
const history = useHistory()
|
||||
const dispatch = useAppDispatch()
|
||||
|
@ -60,8 +61,11 @@ function CenterContent(props: Props) {
|
|||
if ((!property || property.type !== 'select') && activeView.fields.viewType === 'board') {
|
||||
property = board?.fields.cardProperties.find((o) => o.type === 'select')
|
||||
}
|
||||
|
||||
const displayProperty = dateDisplayProperty
|
||||
return (
|
||||
<CenterPanel
|
||||
clientConfig={clientConfig}
|
||||
readonly={props.readonly}
|
||||
board={board}
|
||||
cards={cards}
|
||||
|
@ -69,6 +73,7 @@ function CenterContent(props: Props) {
|
|||
showCard={showCard}
|
||||
activeView={activeView}
|
||||
groupByProperty={property}
|
||||
dateDisplayProperty={displayProperty}
|
||||
views={views}
|
||||
showShared={clientConfig?.enablePublicSharedBoards || false}
|
||||
/>
|
||||
|
|
|
@ -54,6 +54,15 @@ class CsvExporter {
|
|||
const rows: string[][] = []
|
||||
const visibleProperties = board.fields.cardProperties.filter((template: IPropertyTemplate) => viewToExport.fields.visiblePropertyIds.includes(template.id))
|
||||
|
||||
if (viewToExport.fields.viewType === 'calendar' &&
|
||||
viewToExport.fields.dateDisplayPropertyId &&
|
||||
!viewToExport.fields.visiblePropertyIds.includes(viewToExport.fields.dateDisplayPropertyId)) {
|
||||
const dateDisplay = board.fields.cardProperties.find((template: IPropertyTemplate) => viewToExport.fields.dateDisplayPropertyId === template.id)
|
||||
if (dateDisplay) {
|
||||
visibleProperties.push(dateDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Header row
|
||||
const row: string[] = [intl.formatMessage({id: 'TableComponent.name', defaultMessage: 'Name'})]
|
||||
|
|
|
@ -19,6 +19,7 @@ import TelemetryClient, {TelemetryCategory, TelemetryActions} from './telemetry/
|
|||
//
|
||||
class Mutator {
|
||||
private undoGroupId?: string
|
||||
private undoDisplayId?: string
|
||||
|
||||
private beginUndoGroup(): string | undefined {
|
||||
if (this.undoGroupId) {
|
||||
|
@ -42,7 +43,7 @@ class Mutator {
|
|||
try {
|
||||
await actions()
|
||||
} catch (err) {
|
||||
Utils.assertFailure(`ERROR: ${err?.toString?.()}`)
|
||||
Utils.assertFailure(`ERROR: ${err}`)
|
||||
}
|
||||
if (groupId) {
|
||||
this.endUndoGroup(groupId)
|
||||
|
@ -549,6 +550,19 @@ class Mutator {
|
|||
)
|
||||
}
|
||||
|
||||
async changeViewDateDisplayPropertyId(viewId: string, oldDateDisplayPropertyId: string|undefined, dateDisplayPropertyId: string): Promise<void> {
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octoClient.patchBlock(viewId, {updatedFields: {dateDisplayPropertyId}})
|
||||
},
|
||||
async () => {
|
||||
await octoClient.patchBlock(viewId, {updatedFields: {dateDisplayPropertyId: oldDateDisplayPropertyId}})
|
||||
},
|
||||
'display by',
|
||||
this.undoDisplayId,
|
||||
)
|
||||
}
|
||||
|
||||
async changeViewVisibleProperties(viewId: string, oldVisiblePropertyIds: string[], visiblePropertyIds: string[], description = 'show / hide property'): Promise<void> {
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
|
|
|
@ -99,3 +99,17 @@ export const getCurrentViewGroupBy = createSelector(
|
|||
return currentBoard.fields.cardProperties.find((o) => o.id === currentView.fields.groupById)
|
||||
},
|
||||
)
|
||||
|
||||
export const getCurrentViewDisplayBy = createSelector(
|
||||
getCurrentBoard,
|
||||
getCurrentView,
|
||||
(currentBoard, currentView) => {
|
||||
if (!currentBoard) {
|
||||
return undefined
|
||||
}
|
||||
if (!currentView) {
|
||||
return undefined
|
||||
}
|
||||
return currentBoard.fields.cardProperties.find((o) => o.id === currentView.fields.dateDisplayPropertyId)
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue