diff --git a/webapp/src/components/calculations/calculation.test.tsx b/webapp/src/components/calculations/calculation.test.tsx index 065317000..daa23d0ad 100644 --- a/webapp/src/components/calculations/calculation.test.tsx +++ b/webapp/src/components/calculations/calculation.test.tsx @@ -7,6 +7,7 @@ import {render} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {TestBlockFactory} from '../../test/testBlockFactory' +import {wrapIntl} from '../../testUtils' import Calculation from './calculation' @@ -24,7 +25,7 @@ describe('components/calculations/Calculation', () => { card2.fields.properties.property_4 = 'Baz' test('should match snapshot - none', () => { - const component = ( + const component = wrapIntl( { type: 'text', options: [], }} - /> + />, ) const {container} = render(component) @@ -49,7 +50,7 @@ describe('components/calculations/Calculation', () => { }) test('should match snapshot - count', () => { - const component = ( + const component = wrapIntl( { type: 'text', options: [], }} - /> + />, ) const {container} = render(component) @@ -74,7 +75,7 @@ describe('components/calculations/Calculation', () => { }) test('should match snapshot - countValue', () => { - const component = ( + const component = wrapIntl( { type: 'text', options: [], }} - /> + />, ) const {container} = render(component) @@ -99,7 +100,7 @@ describe('components/calculations/Calculation', () => { }) test('should match snapshot - countUniqueValue', () => { - const component = ( + const component = wrapIntl( { type: 'text', options: [], }} - /> + />, ) const {container} = render(component) @@ -128,7 +129,7 @@ describe('components/calculations/Calculation', () => { const onMenuClose = jest.fn() const onChange = jest.fn() - const component = ( + const component = wrapIntl( { type: 'text', options: [], }} - /> + />, ) const {container} = render(component) diff --git a/webapp/src/components/calculations/calculation.tsx b/webapp/src/components/calculations/calculation.tsx index 51f056c72..a2b119bdc 100644 --- a/webapp/src/components/calculations/calculation.tsx +++ b/webapp/src/components/calculations/calculation.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {CSSProperties} from 'react' +import {useIntl} from 'react-intl' import {Card} from '../../blocks/card' @@ -29,6 +30,7 @@ type Props = { const Calculation = (props: Props): JSX.Element => { const value = props.value || Options.none.value const valueOption = Options[value] + const intl = useIntl() return ( @@ -68,7 +70,7 @@ const Calculation = (props: Props): JSX.Element => { { value !== Options.none.value && - {Calculations[value] ? Calculations[value](props.cards, props.property) : ''} + {Calculations[value] ? Calculations[value](props.cards, props.property, intl) : ''} } diff --git a/webapp/src/components/calculations/calculations.test.tsx b/webapp/src/components/calculations/calculations.test.tsx index 3d1c30e99..d4348ec6c 100644 --- a/webapp/src/components/calculations/calculations.test.tsx +++ b/webapp/src/components/calculations/calculations.test.tsx @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {createIntl} from 'react-intl' + import {TestBlockFactory} from '../../test/testBlockFactory' import {IPropertyTemplate} from '../../blocks/board' @@ -149,10 +151,12 @@ describe('components/calculations/calculation logic', () => { updatedBy: {id: 'property_lastUpdatedBy', type: 'updatedBy', name: '', options: []}, } + const intl = createIntl({locale: 'en-us'}) + // testing count Object.values(properties).forEach((property) => { it(`should correctly count for property type "${property.type}"`, function() { - expect(Calculations.count(cards, property)).toBe('4') + expect(Calculations.count(cards, property, intl)).toBe('4') }) }) @@ -175,7 +179,7 @@ describe('components/calculations/calculation logic', () => { } Object.keys(countValueTests).forEach((propertyType) => { it(`should correctly count values for property type ${propertyType}`, function() { - expect(Calculations.countValue(cards, properties[propertyType]!)).toBe(countValueTests[propertyType]!) + expect(Calculations.countValue(cards, properties[propertyType]!, intl)).toBe(countValueTests[propertyType]!) }) }) @@ -198,97 +202,97 @@ describe('components/calculations/calculation logic', () => { } Object.keys(countUniqueValueTests).forEach((propertyType) => { it(`should correctly count unique values for property type ${propertyType}`, function() { - expect(Calculations.countUniqueValue(cards, properties[propertyType]!)).toBe(countUniqueValueTests[propertyType]!) + expect(Calculations.countUniqueValue(cards, properties[propertyType]!, intl)).toBe(countUniqueValueTests[propertyType]!) }) }) test('countUniqueValue for cards created 1 second apart', () => { - const result = Calculations.countUniqueValue([card3, card6], properties.createdTime) + const result = Calculations.countUniqueValue([card3, card6], properties.createdTime, intl) expect(result).toBe('1') }) test('countUniqueValue for cards updated 1 second apart', () => { - const result = Calculations.countUniqueValue([card3, card6], properties.updatedTime) + const result = Calculations.countUniqueValue([card3, card6], properties.updatedTime, intl) expect(result).toBe('1') }) test('countUniqueValue for cards created 1 minute apart', () => { - const result = Calculations.countUniqueValue([card3, card7], properties.createdTime) + const result = Calculations.countUniqueValue([card3, card7], properties.createdTime, intl) expect(result).toBe('2') }) test('countUniqueValue for cards updated 1 minute apart', () => { - const result = Calculations.countUniqueValue([card3, card7], properties.updatedTime) + const result = Calculations.countUniqueValue([card3, card7], properties.updatedTime, intl) expect(result).toBe('2') }) test('countChecked for cards', () => { - const result = Calculations.countChecked(cards, properties.checkbox) + const result = Calculations.countChecked(cards, properties.checkbox, intl) expect(result).toBe('3') }) test('countChecked for cards, one set, other unset', () => { - const result = Calculations.countChecked([card1, card5], properties.checkbox) + const result = Calculations.countChecked([card1, card5], properties.checkbox, intl) expect(result).toBe('1') }) test('countUnchecked for cards', () => { - const result = Calculations.countUnchecked(cards, properties.checkbox) + const result = Calculations.countUnchecked(cards, properties.checkbox, intl) expect(result).toBe('1') }) test('countUnchecked for cards, two set, one unset', () => { - const result = Calculations.countUnchecked([card1, card1, card5], properties.checkbox) + const result = Calculations.countUnchecked([card1, card1, card5], properties.checkbox, intl) expect(result).toBe('1') }) test('countUnchecked for cards, one set, other unset', () => { - const result = Calculations.countUnchecked([card1, card5], properties.checkbox) + const result = Calculations.countUnchecked([card1, card5], properties.checkbox, intl) expect(result).toBe('1') }) test('countUnchecked for cards, one set, two unset', () => { - const result = Calculations.countUnchecked([card1, card5, card5], properties.checkbox) + const result = Calculations.countUnchecked([card1, card5, card5], properties.checkbox, intl) expect(result).toBe('2') }) test('percentChecked for cards', () => { - const result = Calculations.percentChecked(cards, properties.checkbox) + const result = Calculations.percentChecked(cards, properties.checkbox, intl) expect(result).toBe('75%') }) test('percentUnchecked for cards', () => { - const result = Calculations.percentUnchecked(cards, properties.checkbox) + const result = Calculations.percentUnchecked(cards, properties.checkbox, intl) expect(result).toBe('25%') }) test('sum', () => { - const result = Calculations.sum(cards, properties.number) + const result = Calculations.sum(cards, properties.number, intl) expect(result).toBe('170') }) test('average', () => { - const result = Calculations.average(cards, properties.number) + const result = Calculations.average(cards, properties.number, intl) expect(result).toBe('56.67') }) test('median', () => { - const result = Calculations.median(cards, properties.number) + const result = Calculations.median(cards, properties.number, intl) expect(result).toBe('100') }) test('min', () => { - const result = Calculations.min(cards, properties.number) + const result = Calculations.min(cards, properties.number, intl) expect(result).toBe('-30') }) test('max', () => { - const result = Calculations.max(cards, properties.number) + const result = Calculations.max(cards, properties.number, intl) expect(result).toBe('100') }) test('range', () => { - const result = Calculations.range(cards, properties.number) + const result = Calculations.range(cards, properties.number, intl) expect(result).toBe('-30 - 100') }) }) diff --git a/webapp/src/components/calculations/calculations.ts b/webapp/src/components/calculations/calculations.ts index 8b12a3f68..0c73d29c9 100644 --- a/webapp/src/components/calculations/calculations.ts +++ b/webapp/src/components/calculations/calculations.ts @@ -1,10 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IntlShape} from 'react-intl' + +import moment from 'moment' + import {Card} from '../../blocks/card' import {IPropertyTemplate} from '../../blocks/board' import {Utils} from '../../utils' import {Constants} from '../../constants' +import {DateProperty} from '../properties/dateRange/dateRange' const ROUNDED_DECIMAL_PLACES = 2 @@ -197,7 +202,77 @@ function range(cards: readonly Card[], property: IPropertyTemplate): string { return min(cards, property) + ' - ' + max(cards, property) } -const Calculations: Record string> = { +function earliest(cards: readonly Card[], property: IPropertyTemplate, intl: IntlShape): string { + const result = earliestEpoch(cards, property) + if (result === Number.POSITIVE_INFINITY) { + return '' + } + const date = new Date(result) + return property.type === 'date' ? Utils.displayDate(date, intl) : Utils.displayDateTime(date, intl) +} + +function earliestEpoch(cards: readonly Card[], property: IPropertyTemplate): number { + let result = Number.POSITIVE_INFINITY + cards.forEach((card) => { + const timestamps = getTimestampsFromPropertyValue(getCardProperty(card, property)) + for (const timestamp of timestamps) { + result = Math.min(result, timestamp) + } + }) + return result +} + +function latest(cards: readonly Card[], property: IPropertyTemplate, intl: IntlShape): string { + const result = latestEpoch(cards, property) + if (result === Number.NEGATIVE_INFINITY) { + return '' + } + const date = new Date(result) + return property.type === 'date' ? Utils.displayDate(date, intl) : Utils.displayDateTime(date, intl) +} + +function latestEpoch(cards: readonly Card[], property: IPropertyTemplate): number { + let result = Number.NEGATIVE_INFINITY + cards.forEach((card) => { + const timestamps = getTimestampsFromPropertyValue(getCardProperty(card, property)) + for (const timestamp of timestamps) { + result = Math.max(result, timestamp) + } + }) + return result +} + +function getTimestampsFromPropertyValue(value: number | string | string[]): number[] { + if (typeof value === 'number') { + return [value] + } + if (typeof value === 'string') { + let property: DateProperty + try { + property = JSON.parse(value) + } catch { + return [] + } + return [property.from, property.to].flatMap((e) => { + return e ? [e] : [] + }) + } + return [] +} + +function dateRange(cards: readonly Card[], property: IPropertyTemplate): string { + const resultEarliest = earliestEpoch(cards, property) + if (resultEarliest === Number.POSITIVE_INFINITY) { + return '' + } + const resultLatest = latestEpoch(cards, property) + if (resultLatest === Number.NEGATIVE_INFINITY) { + return '' + } + return moment.duration(resultLatest - resultEarliest, 'milliseconds').humanize() +} + +const Calculations: Record string> = { count, countValue, countUniqueValue, @@ -211,6 +286,9 @@ const Calculations: Record = { min: {value: 'min', label: 'Min', displayName: 'Min'}, max: {value: 'max', label: 'Max', displayName: 'Max'}, range: {value: 'range', label: 'Range', displayName: 'Range'}, + earliest: {value: 'earliest', label: 'Earliest', displayName: 'Earliest'}, + latest: {value: 'latest', label: 'Latest', displayName: 'Latest'}, + dateRange: {value: 'dateRange', label: 'Range', displayName: 'Range'}, } const optionsByType: Map = new Map([ ['common', [Options.none, Options.count, Options.countValue, Options.countUniqueValue]], ['checkbox', [Options.countChecked, Options.countUnchecked, Options.percentChecked, Options.percentUnchecked]], ['number', [Options.sum, Options.average, Options.median, Options.min, Options.max, Options.range]], + ['date', [Options.earliest, Options.latest, Options.dateRange]], + ['createdTime', [Options.earliest, Options.latest, Options.dateRange]], + ['updatedTime', [Options.earliest, Options.latest, Options.dateRange]], ]) const baseStyles = getSelectBaseStyle() diff --git a/webapp/src/components/properties/dateRange/dateRange.tsx b/webapp/src/components/properties/dateRange/dateRange.tsx index a700020b1..b632f8951 100644 --- a/webapp/src/components/properties/dateRange/dateRange.tsx +++ b/webapp/src/components/properties/dateRange/dateRange.tsx @@ -26,7 +26,7 @@ type Props = { onChange: (value: string) => void } -type DateProperty = { +export type DateProperty = { from?: number to?: number includeTime?: boolean