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 commit e16e715e8a.

* Revert "updated icons"

This reverts commit 120b7b0b96.

* 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:
Scott Bishel 2021-11-24 14:00:20 -07:00 committed by GitHub
parent 941a47aead
commit 797d6bc04a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 6483 additions and 259 deletions

View file

@ -73,7 +73,6 @@ func (p *Plugin) OnConfigurationChange() error { //nolint
if p.wsPluginAdapter == nil {
return nil
}
mmconfig := p.API.GetConfig()
// handle plugin configuration settings

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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

View 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()
})
})

View 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

View 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;
}
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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/>
}
}

View file

@ -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}
/>
}
</>
}

View 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

View file

@ -55,6 +55,7 @@ describe('/components/viewMenu', () => {
},
current: 'boardView',
},
clientConfig: {},
}
const history = createMemoryHistory()

View file

@ -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>

View file

@ -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}
/>

View file

@ -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'})]

View file

@ -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 () => {

View file

@ -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)
},
)