diff --git a/webapp/src/blocks/board.ts b/webapp/src/blocks/board.ts index 56ae3fb73..d5b5a1810 100644 --- a/webapp/src/blocks/board.ts +++ b/webapp/src/blocks/board.ts @@ -29,6 +29,7 @@ type BoardFields = { showDescription?: boolean isTemplate?: boolean cardProperties: IPropertyTemplate[] + columnCalculations: Record } type Board = Block & { diff --git a/webapp/src/blocks/boardView.ts b/webapp/src/blocks/boardView.ts index 3cf76b46d..f3f97f1e3 100644 --- a/webapp/src/blocks/boardView.ts +++ b/webapp/src/blocks/boardView.ts @@ -9,6 +9,11 @@ import {FilterGroup, createFilterGroup} from './filterGroup' type IViewType = 'board' | 'table' | 'gallery' // | 'calendar' | 'list' type ISortOption = { propertyId: '__title' | string, reversed: boolean } +type KanbanCalculationFields = { + calculation: string + propertyId: string +} + type BoardViewFields = { viewType: IViewType groupById?: string @@ -21,6 +26,7 @@ type BoardViewFields = { cardOrder: string[] columnWidths: Record columnCalculations: Record + kanbanCalculations: Record defaultTemplateId: string } @@ -45,6 +51,7 @@ function createBoardView(block?: Block): BoardView { cardOrder: block?.fields.cardOrder?.slice() || [], columnWidths: {...(block?.fields.columnWidths || {})}, columnCalculations: {...(block?.fields.columnCalculations) || {}}, + kanbanCalculations: {...(block?.fields.kanbanCalculations) || {}}, defaultTemplateId: block?.fields.defaultTemplateId || '', }, } @@ -57,4 +64,4 @@ function sortBoardViewsAlphabetically(views: BoardView[]): BoardView[] { }).sort((v1, v2) => v1.title.localeCompare(v2.title)).map((v) => v.view) } -export {BoardView, IViewType, ISortOption, sortBoardViewsAlphabetically, createBoardView} +export {BoardView, IViewType, ISortOption, sortBoardViewsAlphabetically, createBoardView, KanbanCalculationFields} diff --git a/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap b/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap index 508bb2032..3d5cb49a9 100644 --- a/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap +++ b/webapp/src/components/__snapshots__/propertyValueElement.test.tsx.snap @@ -45,7 +45,7 @@ exports[`components/propertyValueElement should match snapshot, date, array valu class="DateRange empty octo-propertyvalue" > + + +`; diff --git a/webapp/src/components/kanban/calculation/__snapshots__/calculationOptions.test.tsx.snap b/webapp/src/components/kanban/calculation/__snapshots__/calculationOptions.test.tsx.snap new file mode 100644 index 000000000..abad9d2eb --- /dev/null +++ b/webapp/src/components/kanban/calculation/__snapshots__/calculationOptions.test.tsx.snap @@ -0,0 +1,355 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/kanban/calculations/KanbanCalculationOptions base case 1`] = ` +
+
+ + + + Select is focused , press Down to open the menu, + + +
+
+
+ Count +
+ +
+
+ + + +
+
+ +
+
+`; + +exports[`components/kanban/calculations/KanbanCalculationOptions with menu open 1`] = ` +
+
+ + + + 3 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu. + + +
+
+
+ Count +
+ +
+
+ + + +
+
+
+
+
+ + Count + + +
+
+ + Count Value + + + +
+
+ + Count Unique Values + + + +
+
+
+ +
+
+`; + +exports[`components/kanban/calculations/KanbanCalculationOptions with submenu open 1`] = ` +
+
+ + + + 3 results available. Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu. + + +
+
+
+ Count +
+ +
+
+ + + +
+
+
+
+
+ + Count + + +
+
+ + Count Value + + + +
+
+ + Count Unique Values + + + + +
+
+
+ +
+
+`; diff --git a/webapp/src/components/kanban/calculation/__snapshots__/kanbanOption.test.tsx.snap b/webapp/src/components/kanban/calculation/__snapshots__/kanbanOption.test.tsx.snap new file mode 100644 index 000000000..28e67da9a --- /dev/null +++ b/webapp/src/components/kanban/calculation/__snapshots__/kanbanOption.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/kanban/calculations/Option base case 1`] = ` +
+
+ + Count Unique Values + + + +
+
+`; diff --git a/webapp/src/components/kanban/calculation/calculation.scss b/webapp/src/components/kanban/calculation/calculation.scss new file mode 100644 index 000000000..d4aff9948 --- /dev/null +++ b/webapp/src/components/kanban/calculation/calculation.scss @@ -0,0 +1,14 @@ +.KanbanCalculation { + position: relative; + + button { + cursor: pointer !important; + height: 24px; + padding: 0 6px; + min-width: 24px; + + &:hover { + background-color: rgba(var(--center-channel-color-rgb), 0.1); + } + } +} diff --git a/webapp/src/components/kanban/calculation/calculation.test.tsx b/webapp/src/components/kanban/calculation/calculation.test.tsx new file mode 100644 index 000000000..aba3dd33e --- /dev/null +++ b/webapp/src/components/kanban/calculation/calculation.test.tsx @@ -0,0 +1,77 @@ +// 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 {wrapIntl} from '../../../testUtils' + +import {KanbanCalculation} from './calculation' + +describe('components/kanban/calculation/KanbanCalculation', () => { + const board = TestBlockFactory.createBoard() + const cards = [ + TestBlockFactory.createCard(board), + TestBlockFactory.createCard(board), + TestBlockFactory.createCard(board), + ] + + test('base case', () => { + const component = wrapIntl(( + {}} + onMenuOpen={() => {}} + onChange={() => {}} + value={'count'} + property={board.fields.cardProperties[0]} + readonly={false} + /> + )) + + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('calculations menu open', () => { + const component = wrapIntl(( + {}} + onMenuOpen={() => {}} + onChange={() => {}} + value={'count'} + property={board.fields.cardProperties[0]} + readonly={false} + /> + )) + + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('no menu should appear in readonly mode', () => { + const component = wrapIntl(( + {}} + onMenuOpen={() => {}} + onChange={() => {}} + value={'count'} + property={board.fields.cardProperties[0]} + readonly={true} + /> + )) + + const {container} = render(component) + expect(container).toMatchSnapshot() + }) +}) diff --git a/webapp/src/components/kanban/calculation/calculation.tsx b/webapp/src/components/kanban/calculation/calculation.tsx new file mode 100644 index 000000000..6cffba3bc --- /dev/null +++ b/webapp/src/components/kanban/calculation/calculation.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react' +import {useIntl} from 'react-intl' + +import {Card} from '../../../blocks/card' +import Button from '../../../widgets/buttons/button' +import './calculation.scss' +import {IPropertyTemplate} from '../../../blocks/board' + +import Calculations from '../../calculations/calculations' + +import {KanbanCalculationOptions} from './calculationOptions' + +type Props = { + cards: Card[] + cardProperties: IPropertyTemplate[] + menuOpen: boolean + onMenuClose: () => void + onMenuOpen: () => void + onChange: (data: { calculation: string, propertyId: string }) => void + value: string + property: IPropertyTemplate + readonly: boolean +} + +function KanbanCalculation(props: Props): JSX.Element { + const intl = useIntl() + + return ( +
+ + + { + !props.readonly && props.menuOpen && ( + { + props.onChange(data) + props.onMenuClose() + }} + cardProperties={props.cardProperties} + /> + ) + } +
+ ) +} + +export { + KanbanCalculation, +} diff --git a/webapp/src/components/kanban/calculation/calculationOption.scss b/webapp/src/components/kanban/calculation/calculationOption.scss new file mode 100644 index 000000000..48db33384 --- /dev/null +++ b/webapp/src/components/kanban/calculation/calculationOption.scss @@ -0,0 +1,63 @@ +.KanbanCalculationOptions_CustomOption { + color: rgba(var(--center-channel-color-rgb), 1); + min-height: 24px; + max-width: 220px; + width: 200px; + padding: 2px 12px; + cursor: pointer; + position: relative; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.1); + } + + span { + overflow: hidden; + } + + .drops { + padding: 2px 14px; + width: 200px; + + &.active, + &.active:hover { + background-color: rgb(var(--sidebar-bg-rgb)); + color: rgb(var(--sidebar-text-rgb)); + } + } + + .customs:hover, + .drops:hover { + background: rgba(var(--center-channel-color-rgb), 0.1); + } + + .dropdown-submenu { + position: fixed; + overflow: auto; + background: rgb(var(--center-channel-bg-rgb)); + border: 0; + box-shadow: 0 0 0 1px hsla(0, 0%, 0%, 0.1), 0 4px 11px hsla(0, 0%, 0%, 0.1); + z-index: 999; + padding: 6px 0; + border-radius: 4px; + } + + .CompassIcon.icon-chevron-right { + float: right; + } + + &.active { + background-color: rgb(var(--sidebar-bg-rgb)); + + > span { + color: rgb(var(--sidebar-text-rgb)); + } + } +} + +.CalculationOptions__menu { + .CalculationOptions__menu-list { + max-height: 400px; + padding: 8px 0; + } +} diff --git a/webapp/src/components/kanban/calculation/calculationOptions.test.tsx b/webapp/src/components/kanban/calculation/calculationOptions.test.tsx new file mode 100644 index 000000000..9d0f3cf8b --- /dev/null +++ b/webapp/src/components/kanban/calculation/calculationOptions.test.tsx @@ -0,0 +1,63 @@ +// 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 {TestBlockFactory} from '../../../test/testBlockFactory' + +import {KanbanCalculationOptions} from './calculationOptions' + +describe('components/kanban/calculations/KanbanCalculationOptions', () => { + const board = TestBlockFactory.createBoard() + + test('base case', () => { + const component = ( + {}} + cardProperties={board.fields.cardProperties} + /> + ) + + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('with menu open', () => { + const component = ( + {}} + cardProperties={board.fields.cardProperties} + /> + ) + + const {container} = render(component) + expect(container).toMatchSnapshot() + }) + + test('with submenu open', () => { + const component = ( + {}} + cardProperties={board.fields.cardProperties} + /> + ) + + const {container, getByText} = render(component) + const countUniqueValuesOption = getByText('Count Unique Values') + expect(countUniqueValuesOption).toBeDefined() + userEvent.hover(countUniqueValuesOption) + expect(container).toMatchSnapshot() + }) +}) diff --git a/webapp/src/components/kanban/calculation/calculationOptions.tsx b/webapp/src/components/kanban/calculation/calculationOptions.tsx new file mode 100644 index 000000000..9f82f0b63 --- /dev/null +++ b/webapp/src/components/kanban/calculation/calculationOptions.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react' + +import { + CalculationOptions, + CommonCalculationOptionProps, + optionsByType, +} from '../../calculations/options' +import {IPropertyTemplate} from '../../../blocks/board' + +import './calculationOption.scss' +import {Option, OptionProps} from './kanbanOption' + +type Props = CommonCalculationOptionProps & { + cardProperties: IPropertyTemplate[] + onChange: (data: {calculation: string, propertyId: string}) => void +} + +export const KanbanCalculationOptions = (props: Props): JSX.Element => { + const options: OptionProps[] = [] + + // Show common options, first, + // followed by type-specific functions + optionsByType.get('common')!.forEach((typeOption) => { + if (typeOption.value !== 'none') { + options.push({ + ...typeOption, + cardProperties: props.cardProperties, + onChange: props.onChange, + activeValue: props.value, + activeProperty: props.property!, + }) + } + }) + + props.cardProperties. + map((property) => optionsByType.get(property.type) || []). + forEach((typeOptions) => { + typeOptions.forEach((typeOption) => { + options.push({ + ...typeOption, + cardProperties: props.cardProperties, + onChange: props.onChange, + activeValue: props.value, + activeProperty: props.property!, + }) + }) + }) + + return ( + + ) +} diff --git a/webapp/src/components/kanban/calculation/kanbanOption.test.tsx b/webapp/src/components/kanban/calculation/kanbanOption.test.tsx new file mode 100644 index 000000000..f0d33d116 --- /dev/null +++ b/webapp/src/components/kanban/calculation/kanbanOption.test.tsx @@ -0,0 +1,32 @@ +// 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 {Option} from './kanbanOption' + +describe('components/kanban/calculations/Option', () => { + const board = TestBlockFactory.createBoard() + + test('base case', () => { + const component = ( +