From 5f1697eb8108273b156060ffb100a2b3f9a6f950 Mon Sep 17 00:00:00 2001 From: Antonio Rivero <38889534+Antonio-RiveroMartnez@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:10:04 +0100 Subject: [PATCH] feat(plugins): Adding colors to BigNumber with Time Comparison chart (#27052) (cherry picked from commit e8e208dd14b132339b5187b7368e86326a44e3f4) --- .../src/PopKPI.tsx | 104 ++++++-- .../src/images/thumbnail.png | Bin 23099 -> 10434 bytes .../src/plugin/buildQuery.ts | 208 +-------------- .../src/plugin/controlPanel.ts | 12 + .../src/plugin/transformProps.ts | 5 +- .../src/types.ts | 19 +- .../src/utils.ts | 246 ++++++++++++++++++ 7 files changed, 369 insertions(+), 225 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-period-over-period-kpi/src/utils.ts 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 30c9e07b0ccae4a12fda680d3b3b111b65230c99..3be299145ba5426e50267f6fedaa99d7f1fd953e 100644 GIT binary patch literal 10434 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 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; +};