[GH-1080] Add table functions for dates (#1508)

This adds earliest, latest and range column aggregations for general
date as well as created/updated date properties.

Fixes: #1080
This commit is contained in:
Johannes Marbach 2021-10-12 13:31:38 +02:00 committed by GitHub
parent c9aeeb38bf
commit 6ec084f93d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 125 additions and 34 deletions

View File

@ -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(
<Calculation
style={{}}
class={'fooClass'}
@ -41,7 +42,7 @@ describe('components/calculations/Calculation', () => {
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(
<Calculation
style={{}}
class={'fooClass'}
@ -66,7 +67,7 @@ describe('components/calculations/Calculation', () => {
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(
<Calculation
style={{}}
class={'fooClass'}
@ -91,7 +92,7 @@ describe('components/calculations/Calculation', () => {
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(
<Calculation
style={{}}
class={'fooClass'}
@ -116,7 +117,7 @@ describe('components/calculations/Calculation', () => {
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(
<Calculation
style={{}}
class={'fooClass'}
@ -145,7 +146,7 @@ describe('components/calculations/Calculation', () => {
type: 'text',
options: [],
}}
/>
/>,
)
const {container} = render(component)

View File

@ -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 &&
<span className='calculationValue'>
{Calculations[value] ? Calculations[value](props.cards, props.property) : ''}
{Calculations[value] ? Calculations[value](props.cards, props.property, intl) : ''}
</span>
}

View File

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

View File

@ -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, (cards: readonly Card[], property: IPropertyTemplate) => 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, (cards: readonly Card[], property: IPropertyTemplate, intl: IntlShape) => string> = {
count,
countValue,
countUniqueValue,
@ -211,6 +286,9 @@ const Calculations: Record<string, (cards: readonly Card[], property: IPropertyT
min,
max,
range,
earliest,
latest,
dateRange,
}
export default Calculations

View File

@ -31,12 +31,18 @@ const Options:Record<string, Option> = {
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<string, Option[]> = 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()

View File

@ -26,7 +26,7 @@ type Props = {
onChange: (value: string) => void
}
type DateProperty = {
export type DateProperty = {
from?: number
to?: number
includeTime?: boolean