diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/PopKPI.tsx b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/PopKPI.tsx index e780e93ca4efb..85156ae951608 100644 --- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/PopKPI.tsx +++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/PopKPI.tsx @@ -16,9 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import React, { createRef } from 'react'; +import React, { createRef, useMemo } from 'react'; import { css, styled, useTheme } from '@superset-ui/core'; -import { PopKPIComparisonValueStyleProps, PopKPIProps } from './types'; +import { + PopKPIComparisonSymbolStyleProps, + PopKPIComparisonValueStyleProps, + PopKPIProps, +} from './types'; const ComparisonValue = styled.div` ${({ theme, subheaderFontSize }) => ` @@ -30,6 +34,17 @@ const ComparisonValue = styled.div` `} `; +const SymbolWrapper = styled.div` + ${({ theme, backgroundColor, textColor }) => ` + background-color: ${backgroundColor}; + color: ${textColor}; + padding: ${theme.gridUnit}px ${theme.gridUnit * 2}px; + border-radius: ${theme.gridUnit * 2}px; + display: inline-block; + margin-right: ${theme.gridUnit}px; + `} +`; + export default function PopKPI(props: PopKPIProps) { const { height, @@ -37,9 +52,11 @@ export default function PopKPI(props: PopKPIProps) { bigNumber, prevNumber, valueDifference, - percentDifference, + percentDifferenceFormattedString, headerFontSize, subheaderFontSize, + comparisonColorEnabled, + percentDifferenceNumber, } = props; const rootElem = createRef(); @@ -63,9 +80,60 @@ export default function PopKPI(props: PopKPIProps) { text-align: center; `; + const getArrowIndicatorColor = () => { + if (!comparisonColorEnabled) return theme.colors.grayscale.base; + return percentDifferenceNumber > 0 + ? theme.colors.success.base + : theme.colors.error.base; + }; + + const arrowIndicatorStyle = css` + color: ${getArrowIndicatorColor()}; + margin-left: ${theme.gridUnit}px; + `; + + const defaultBackgroundColor = theme.colors.grayscale.light4; + const defaultTextColor = theme.colors.grayscale.base; + const { backgroundColor, textColor } = useMemo(() => { + let bgColor = defaultBackgroundColor; + let txtColor = defaultTextColor; + if (percentDifferenceNumber > 0) { + if (comparisonColorEnabled) { + bgColor = theme.colors.success.light2; + txtColor = theme.colors.success.base; + } + } else if (percentDifferenceNumber < 0) { + if (comparisonColorEnabled) { + bgColor = theme.colors.error.light2; + txtColor = theme.colors.error.base; + } + } + + return { + backgroundColor: bgColor, + textColor: txtColor, + }; + }, [theme, comparisonColorEnabled, percentDifferenceNumber]); + + const SYMBOLS_WITH_VALUES = useMemo( + () => [ + ['#', prevNumber], + ['△', valueDifference], + ['%', percentDifferenceFormattedString], + ], + [prevNumber, valueDifference, percentDifferenceFormattedString], + ); + return (
-
{bigNumber}
+
+ {bigNumber} + {percentDifferenceNumber !== 0 && ( + + {percentDifferenceNumber > 0 ? '↑' : '↓'} + + )} +
- - {' '} - #: {prevNumber} - - - {' '} - Δ: {valueDifference} - - - {' '} - %: {percentDifference} - + {SYMBOLS_WITH_VALUES.map((symbol_with_value, index) => ( + + 0 ? backgroundColor : defaultBackgroundColor + } + textColor={index > 0 ? textColor : defaultTextColor} + > + {symbol_with_value[0]} + + {symbol_with_value[1]} + + ))}
diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/images/thumbnail.png index 30c9e07b0ccae..3be299145ba54 100644 Binary files a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/images/thumbnail.png and b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts index aa0477e48f5fd..38346007b4078 100644 --- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/buildQuery.ts @@ -21,7 +21,7 @@ import { buildQueryContext, QueryFormData, } from '@superset-ui/core'; -import moment, { Moment } from 'moment'; +import { computeQueryBComparator } from '../utils'; /** * The buildQuery function is used to create an instance of QueryContext that's @@ -38,184 +38,6 @@ import moment, { Moment } from 'moment'; * if a viz needs multiple different result sets. */ -type MomentTuple = [moment.Moment | null, moment.Moment | null]; - -function getSinceUntil( - timeRange: string | null = null, - relativeStart: string | null = null, - relativeEnd: string | null = null, -): MomentTuple { - const separator = ' : '; - const effectiveRelativeStart = relativeStart || 'today'; - const effectiveRelativeEnd = relativeEnd || 'today'; - - if (!timeRange) { - return [null, null]; - } - - let modTimeRange: string | null = timeRange; - - if (timeRange === 'NO_TIME_RANGE' || timeRange === '_(NO_TIME_RANGE)') { - return [null, null]; - } - - if (timeRange?.startsWith('last') && !timeRange.includes(separator)) { - modTimeRange = timeRange + separator + effectiveRelativeEnd; - } - - if (timeRange?.startsWith('next') && !timeRange.includes(separator)) { - modTimeRange = effectiveRelativeStart + separator + timeRange; - } - - if ( - timeRange?.startsWith('previous calendar week') && - !timeRange.includes(separator) - ) { - return [ - moment().subtract(1, 'week').startOf('week'), - moment().startOf('week'), - ]; - } - - if ( - timeRange?.startsWith('previous calendar month') && - !timeRange.includes(separator) - ) { - return [ - moment().subtract(1, 'month').startOf('month'), - moment().startOf('month'), - ]; - } - - if ( - timeRange?.startsWith('previous calendar year') && - !timeRange.includes(separator) - ) { - return [ - moment().subtract(1, 'year').startOf('year'), - moment().startOf('year'), - ]; - } - - const timeRangeLookup: Array<[RegExp, (...args: string[]) => Moment]> = [ - [ - /^last\s+(day|week|month|quarter|year)$/i, - (unit: string) => - moment().subtract(1, unit as moment.unitOfTime.DurationConstructor), - ], - [ - /^last\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s?$/i, - (delta: string, unit: string) => - moment().subtract(delta, unit as moment.unitOfTime.DurationConstructor), - ], - [ - /^next\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s?$/i, - (delta: string, unit: string) => - moment().add(delta, unit as moment.unitOfTime.DurationConstructor), - ], - [ - // eslint-disable-next-line no-useless-escape - /DATEADD\(DATETIME\("([^"]+)"\),\s*(-?\d+),\s*([^\)]+)\)/i, - (timePart: string, delta: string, unit: string) => { - if (timePart === 'now') { - return moment().add( - delta, - unit as moment.unitOfTime.DurationConstructor, - ); - } - if (moment(timePart.toUpperCase(), true).isValid()) { - return moment(timePart).add( - delta, - unit as moment.unitOfTime.DurationConstructor, - ); - } - return moment(); - }, - ], - ]; - - const sinceAndUntilPartition = modTimeRange - .split(separator, 2) - .map(part => part.trim()); - - const sinceAndUntil: (Moment | null)[] = sinceAndUntilPartition.map(part => { - if (!part) { - return null; - } - - let transformedValue: Moment | null = null; - // Matching time_range_lookup - const matched = timeRangeLookup.some(([pattern, fn]) => { - const result = part.match(pattern); - if (result) { - transformedValue = fn(...result.slice(1)); - return true; - } - - if (part === 'today') { - transformedValue = moment().startOf('day'); - return true; - } - - if (part === 'now') { - transformedValue = moment(); - return true; - } - return false; - }); - - if (matched && transformedValue !== null) { - // Handle the transformed value - } else { - // Handle the case when there was no match - transformedValue = moment(`${part}`); - } - - return transformedValue; - }); - - const [_since, _until] = sinceAndUntil; - - if (_since && _until && _since.isAfter(_until)) { - throw new Error('From date cannot be larger than to date'); - } - - return [_since, _until]; -} - -function calculatePrev( - startDate: Moment | null, - endDate: Moment | null, - calcType: String, -) { - if (!startDate || !endDate) { - return [null, null]; - } - - const daysBetween = endDate.diff(startDate, 'days'); - - let startDatePrev = moment(); - let endDatePrev = moment(); - if (calcType === 'y') { - startDatePrev = startDate.subtract(1, 'year'); - endDatePrev = endDate.subtract(1, 'year'); - } else if (calcType === 'w') { - startDatePrev = startDate.subtract(1, 'week'); - endDatePrev = endDate.subtract(1, 'week'); - } else if (calcType === 'm') { - startDatePrev = startDate.subtract(1, 'month'); - endDatePrev = endDate.subtract(1, 'month'); - } else if (calcType === 'r') { - startDatePrev = startDate.clone().subtract(daysBetween.valueOf(), 'day'); - endDatePrev = startDate; - } else { - startDatePrev = startDate.subtract(1, 'year'); - endDatePrev = endDate.subtract(1, 'year'); - } - - return [startDatePrev, endDatePrev]; -} - export default function buildQuery(formData: QueryFormData) { const { cols: groupby, @@ -240,37 +62,19 @@ export default function buildQuery(formData: QueryFormData) { ? formData.adhoc_filters[timeFilterIndex] : null; - let testSince = null; - let testUntil = null; - - if ( - timeFilter && - 'comparator' in timeFilter && - typeof timeFilter.comparator === 'string' - ) { - let timeRange = timeFilter.comparator.toLocaleLowerCase(); - if (extraFormData?.time_range) { - timeRange = extraFormData.time_range; - } - [testSince, testUntil] = getSinceUntil(timeRange); - } - let formDataB: QueryFormData; + let queryBComparator = null; if (timeComparison !== 'c') { - const [prevStartDateMoment, prevEndDateMoment] = calculatePrev( - testSince, - testUntil, + queryBComparator = computeQueryBComparator( + formData.adhoc_filters || [], timeComparison, + extraFormData, ); - const queryBComparator = `${prevStartDateMoment?.format( - 'YYYY-MM-DDTHH:mm:ss', - )} : ${prevEndDateMoment?.format('YYYY-MM-DDTHH:mm:ss')}`; - const queryBFilter: any = { ...timeFilter, - comparator: queryBComparator.replace(/Z/g, ''), + comparator: queryBComparator, }; const otherFilters = formData.adhoc_filters?.filter( diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts index 89afdb4835fec..3d2504f639077 100644 --- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/controlPanel.ts @@ -181,6 +181,18 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'comparison_color_enabled', + config: { + type: 'CheckboxControl', + label: t('Add color for positive/negative change'), + renderTrigger: true, + default: false, + description: t('Add color for positive/negative change'), + }, + }, + ], ], }, ], diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts index 80737f6032feb..e5de882f6d146 100644 --- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/plugin/transformProps.ts @@ -81,6 +81,7 @@ export default function transformProps(chartProps: ChartProps) { yAxisFormat, currencyFormat, subheaderFontSize, + comparisonColorEnabled, } = formData; const { data: dataA = [] } = queriesData[0]; const { data: dataB = [] } = queriesData[1]; @@ -138,11 +139,13 @@ export default function transformProps(chartProps: ChartProps) { bigNumber, prevNumber, valueDifference, - percentDifference, + percentDifferenceFormattedString: percentDifference, boldText, headerFontSize, subheaderFontSize, headerText, compType, + comparisonColorEnabled, + percentDifferenceNumber: percentDifferenceNum, }; } diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/types.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/types.ts index b13f2115ef819..a239a295935fa 100644 --- a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/types.ts @@ -29,6 +29,7 @@ export interface PopKPIStylesProps { headerFontSize: keyof typeof supersetTheme.typography.sizes; subheaderFontSize: keyof typeof supersetTheme.typography.sizes; boldText: boolean; + comparisonColorEnabled: boolean; } interface PopKPICustomizeProps { @@ -39,6 +40,11 @@ export interface PopKPIComparisonValueStyleProps { subheaderFontSize?: keyof typeof supersetTheme.typography.sizes; } +export interface PopKPIComparisonSymbolStyleProps { + backgroundColor: string; + textColor: string; +} + export type PopKPIQueryFormData = QueryFormData & PopKPIStylesProps & PopKPICustomizeProps; @@ -47,10 +53,11 @@ export type PopKPIProps = PopKPIStylesProps & PopKPICustomizeProps & { data: TimeseriesDataRecord[]; metrics: Metric[]; - metricName: String; - bigNumber: Number; - prevNumber: Number; - valueDifference: Number; - percentDifference: Number; - compType: String; + metricName: string; + bigNumber: string; + prevNumber: string; + valueDifference: string; + percentDifferenceFormattedString: string; + compType: string; + percentDifferenceNumber: number; }; diff --git a/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/utils.ts b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/utils.ts new file mode 100644 index 0000000000000..4ce2ff1e4c28c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/utils.ts @@ -0,0 +1,246 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AdhocFilter } from '@superset-ui/core'; +import moment, { Moment } from 'moment'; + +type MomentTuple = [moment.Moment | null, moment.Moment | null]; + +const getSinceUntil = ( + timeRange: string | null = null, + relativeStart: string | null = null, + relativeEnd: string | null = null, +): MomentTuple => { + const separator = ' : '; + const effectiveRelativeStart = relativeStart || 'today'; + const effectiveRelativeEnd = relativeEnd || 'today'; + + if (!timeRange) { + return [null, null]; + } + + let modTimeRange: string | null = timeRange; + + if (timeRange === 'NO_TIME_RANGE' || timeRange === '_(NO_TIME_RANGE)') { + return [null, null]; + } + + if (timeRange?.startsWith('last') && !timeRange.includes(separator)) { + modTimeRange = timeRange + separator + effectiveRelativeEnd; + } + + if (timeRange?.startsWith('next') && !timeRange.includes(separator)) { + modTimeRange = effectiveRelativeStart + separator + timeRange; + } + + if ( + timeRange?.startsWith('previous calendar week') && + !timeRange.includes(separator) + ) { + return [ + moment().subtract(1, 'week').startOf('week'), + moment().startOf('week'), + ]; + } + + if ( + timeRange?.startsWith('previous calendar month') && + !timeRange.includes(separator) + ) { + return [ + moment().subtract(1, 'month').startOf('month'), + moment().startOf('month'), + ]; + } + + if ( + timeRange?.startsWith('previous calendar year') && + !timeRange.includes(separator) + ) { + return [ + moment().subtract(1, 'year').startOf('year'), + moment().startOf('year'), + ]; + } + + const timeRangeLookup: Array<[RegExp, (...args: string[]) => Moment]> = [ + [ + /^last\s+(day|week|month|quarter|year)$/i, + (unit: string) => + moment().subtract(1, unit as moment.unitOfTime.DurationConstructor), + ], + [ + /^last\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s?$/i, + (delta: string, unit: string) => + moment().subtract(delta, unit as moment.unitOfTime.DurationConstructor), + ], + [ + /^next\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s?$/i, + (delta: string, unit: string) => + moment().add(delta, unit as moment.unitOfTime.DurationConstructor), + ], + [ + // eslint-disable-next-line no-useless-escape + /DATEADD\(DATETIME\("([^"]+)"\),\s*(-?\d+),\s*([^\)]+)\)/i, + (timePart: string, delta: string, unit: string) => { + if (timePart === 'now') { + return moment().add( + delta, + unit as moment.unitOfTime.DurationConstructor, + ); + } + if (moment(timePart.toUpperCase(), true).isValid()) { + return moment(timePart).add( + delta, + unit as moment.unitOfTime.DurationConstructor, + ); + } + return moment(); + }, + ], + ]; + + const sinceAndUntilPartition = modTimeRange + .split(separator, 2) + .map(part => part.trim()); + + const sinceAndUntil: (Moment | null)[] = sinceAndUntilPartition.map(part => { + if (!part) { + return null; + } + + let transformedValue: Moment | null = null; + // Matching time_range_lookup + const matched = timeRangeLookup.some(([pattern, fn]) => { + const result = part.match(pattern); + if (result) { + transformedValue = fn(...result.slice(1)); + return true; + } + + if (part === 'today') { + transformedValue = moment().startOf('day'); + return true; + } + + if (part === 'now') { + transformedValue = moment(); + return true; + } + return false; + }); + + if (matched && transformedValue !== null) { + // Handle the transformed value + } else { + // Handle the case when there was no match + transformedValue = moment(`${part}`); + } + + return transformedValue; + }); + + const [_since, _until] = sinceAndUntil; + + if (_since && _until && _since.isAfter(_until)) { + throw new Error('From date cannot be larger than to date'); + } + + return [_since, _until]; +}; + +const calculatePrev = ( + startDate: Moment | null, + endDate: Moment | null, + calcType: String, +) => { + if (!startDate || !endDate) { + return [null, null]; + } + + const daysBetween = endDate.diff(startDate, 'days'); + + let startDatePrev = moment(); + let endDatePrev = moment(); + if (calcType === 'y') { + startDatePrev = startDate.subtract(1, 'year'); + endDatePrev = endDate.subtract(1, 'year'); + } else if (calcType === 'w') { + startDatePrev = startDate.subtract(1, 'week'); + endDatePrev = endDate.subtract(1, 'week'); + } else if (calcType === 'm') { + startDatePrev = startDate.subtract(1, 'month'); + endDatePrev = endDate.subtract(1, 'month'); + } else if (calcType === 'r') { + startDatePrev = startDate.clone().subtract(daysBetween.valueOf(), 'day'); + endDatePrev = startDate; + } else { + startDatePrev = startDate.subtract(1, 'year'); + endDatePrev = endDate.subtract(1, 'year'); + } + + return [startDatePrev, endDatePrev]; +}; + +export const computeQueryBComparator = ( + adhocFilters: AdhocFilter[], + timeComparison: string, + extraFormData: any, + join = ':', +) => { + const timeFilterIndex = + adhocFilters?.findIndex( + filter => 'operator' in filter && filter.operator === 'TEMPORAL_RANGE', + ) ?? -1; + + const timeFilter = + timeFilterIndex !== -1 ? adhocFilters[timeFilterIndex] : null; + + let testSince = null; + let testUntil = null; + + if ( + timeFilter && + 'comparator' in timeFilter && + typeof timeFilter.comparator === 'string' + ) { + let timeRange = timeFilter.comparator.toLocaleLowerCase(); + if (extraFormData?.time_range) { + timeRange = extraFormData.time_range; + } + [testSince, testUntil] = getSinceUntil(timeRange); + } + + if (timeComparison !== 'c') { + const [prevStartDateMoment, prevEndDateMoment] = calculatePrev( + testSince, + testUntil, + timeComparison, + ); + + return `${prevStartDateMoment?.format( + 'YYYY-MM-DDTHH:mm:ss', + )} ${join} ${prevEndDateMoment?.format('YYYY-MM-DDTHH:mm:ss')}`.replace( + /Z/g, + '', + ); + } + + return null; +};