diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js index cad5d96c8bf1..2e4be8c74f3d 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js @@ -147,8 +147,6 @@ describe('No Results', () => { cy.visitChartByParams(formData); cy.wait('@v1Data').its('response.statusCode').should('eq', 200); - cy.get('div.chart-container').contains( - 'No data', - ); + cy.get('div.chart-container').contains('No data'); }); }); diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index b3c934a06712..d08fe1a008b9 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -41,6 +41,7 @@ export enum FeatureFlag { EmbeddableCharts = 'EMBEDDABLE_CHARTS', EmbeddedSuperset = 'EMBEDDED_SUPERSET', EnableAdvancedDataTypes = 'ENABLE_ADVANCED_DATA_TYPES', + EnableChartForceRefresh = 'ENABLE_CHART_FORCE_REFRESH', /** @deprecated */ EnableJavascriptControls = 'ENABLE_JAVASCRIPT_CONTROLS', EnableTemplateProcessing = 'ENABLE_TEMPLATE_PROCESSING', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/buildQuery.ts index e714898b024e..f5e89af59546 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/buildQuery.ts @@ -18,6 +18,183 @@ */ import { buildQueryContext, QueryFormData } from '@superset-ui/core'; +/** + * Detect time period from time range string + * Examples: "Last 7 days" -> "7 days ago", "Last 1 month" -> "1 month ago" + */ +function detectTimePeriod(timeRange: string): string | null { + const match = timeRange.match(/last\s+(\d+)\s+(day|week|month|year)s?/i); + if (match) { + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + // Handle singular vs plural + const singularUnit = value === 1 ? unit : `${unit}s`; + return `${value} ${singularUnit} ago`; + } + + // Handle special cases + const lowerTimeRange = timeRange.toLowerCase(); + if (lowerTimeRange.includes('this week')) return '1 week ago'; + if (lowerTimeRange.includes('this month')) return '1 month ago'; + if (lowerTimeRange.includes('this year')) return '1 year ago'; + + return null; +} + +/** + * Calculate time period from since/until dates + * Example: since="2023-08-23", until="2023-08-26" -> "4 days ago" + */ +function calculatePeriodFromDates(since: string, until: string): string | null { + try { + const sinceDate = new Date(since); + const untilDate = new Date(until); + const diffTime = Math.abs(untilDate.getTime() - sinceDate.getTime()); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 to include both dates + + if (diffDays === 1) return '1 day ago'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`; + } + if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return months === 1 ? '1 month ago' : `${months} months ago`; + } + const years = Math.floor(diffDays / 365); + return years === 1 ? '1 year ago' : `${years} years ago`; + } catch (error) { + console.warn('Error calculating period from dates:', error); + return null; + } +} + export default function buildQuery(formData: QueryFormData) { - return buildQueryContext(formData, baseQueryObject => [baseQueryObject]); + // Debug logging for buildQuery + console.group('๐Ÿ”ง BigNumberTotal buildQuery - COMPREHENSIVE DEBUG'); + console.log('๐Ÿ“ฅ Input formData analysis:', { + hasExtraFormData: !!formData.extra_form_data, + extraFormData: formData.extra_form_data, + customFormData: formData.extra_form_data?.custom_form_data, + timeCompare: formData.time_compare, + timeCompareType: typeof formData.time_compare, + timeCompareExtra: (formData.extra_form_data?.custom_form_data as any) + ?.time_compare, + timeCompareDirect: (formData.extra_form_data as any)?.time_compare, + allFormDataKeys: Object.keys(formData), + metrics: formData.metrics, + datasource: formData.datasource, + timeRange: formData.time_range, + since: formData.since, + until: formData.until, + }); + console.groupEnd(); + + const buildQuery = (baseQueryObject: any) => { + console.log( + 'BigNumberTotal buildQuery - baseQueryObject:', + baseQueryObject, + ); + + // Use the proper Superset single-query approach for time comparison + // This ensures that formData.time_compare is preserved and passed to transformProps + const time_offsets = (() => { + const timeCompare = + formData.time_compare || + (formData.extra_form_data as any)?.time_compare; + + // Convert string time comparison to array format for transformProps + if (timeCompare && timeCompare !== 'NoComparison') { + if (timeCompare === 'inherit') { + // Enhanced inherit logic: detect actual time period and apply same offset + const timeRange = formData.time_range; + const { since } = formData; + const { until } = formData; + + console.log('BigNumberTotal buildQuery - Inherit logic analysis:', { + timeRange, + since, + until, + hasTimeRange: !!timeRange, + hasSinceUntil: !!(since && until), + }); + + if (timeRange && typeof timeRange === 'string') { + // Parse time range string to detect period + const period = detectTimePeriod(timeRange); + if (period) { + console.log( + 'BigNumberTotal buildQuery - Detected period from timeRange:', + period, + ); + return [period]; + } + } + + if (since && until) { + // Calculate period from since/until dates + const period = calculatePeriodFromDates(since, until); + if (period) { + console.log( + 'BigNumberTotal buildQuery - Calculated period from dates:', + period, + ); + return [period]; + } + } + + // Fallback to default + console.log( + 'BigNumberTotal buildQuery - Using fallback period: 1 day ago', + ); + return ['1 day ago']; + } + if (timeCompare === 'custom') { + // For custom, use the time_compare_value + const customValue = + formData.time_compare_value || + (formData.extra_form_data as any)?.time_compare_value; + return customValue ? [customValue] : []; + } + // For direct string values like "1 day ago", "1 week ago", etc. + return Array.isArray(timeCompare) ? timeCompare : [timeCompare]; + } + return []; + })(); + + return [ + { + ...baseQueryObject, + ...(time_offsets.length > 0 ? { time_offsets } : {}), + }, + ]; + }; + + // Ensure time_compare is preserved on the root formData object + const timeCompare = + formData.time_compare || (formData.extra_form_data as any)?.time_compare; + + if (timeCompare && !formData.time_compare) { + // eslint-disable-next-line no-param-reassign + formData.time_compare = timeCompare; + console.log( + 'BigNumberTotal buildQuery - Setting time_compare on root formData:', + timeCompare, + ); + } + + const result = buildQueryContext(formData, buildQuery); + console.log( + 'BigNumberTotal buildQuery - Final result from buildQueryContext:', + { + result, + hasQueries: !!result.queries, + queriesLength: result.queries?.length, + formData: result.form_data, + timeCompare: result.form_data?.time_compare, + }, + ); + + return result; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts index 5509bc501917..4c5a22380e1f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/controlPanel.ts @@ -23,7 +23,9 @@ import { D3_TIME_FORMAT_OPTIONS, Dataset, getStandardizedControls, + sections, } from '@superset-ui/chart-controls'; + import { headerFontSize, subheaderFontSize } from '../sharedControls'; export default { @@ -57,7 +59,9 @@ export default { type: 'HiddenControl', label: t('URL parameters'), hidden: true, - description: t('Extra parameters for use in jinja templated queries'), + description: t( + 'Extra parameters for use in jinja templated queries', + ), }, }, ], @@ -70,6 +74,7 @@ export default { expanded: true, controlSetRows: [['metric'], ['adhoc_filters']], }, + sections.timeComparisonControls({ multi: false }), { label: t('Display settings'), expanded: true, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts index 9275697f99c7..747eab4c54c4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts @@ -63,6 +63,293 @@ export default function transformProps( const bigNumber = data.length === 0 ? null : parseMetricValue(data[0][metricName]); + // Handle comparison data if available and time comparison is enabled + let previousPeriodValue: number | null = null; + let percentageChange: number | undefined; + let comparisonIndicator: 'positive' | 'negative' | 'neutral' | undefined; + + // Handle time comparison - check all possible locations where time_compare might be stored + let timeCompare = + formData.time_compare || + (formData.extra_form_data?.custom_form_data as any)?.time_compare || + (formData.extra_form_data as any)?.time_compare; + + // If we have time-offset columns but no timeCompare detected, force it to 'inherit' + // This handles cases where the UI selection isn't properly propagated to formData + const hasTimeOffsetColumns = queriesData[0]?.colnames?.some( + (col: string) => col.includes('__') && col !== metricName, + ); + + if (!timeCompare && hasTimeOffsetColumns) { + timeCompare = 'inherit'; + console.log( + 'BigNumberTotal transformProps - Forcing timeCompare to inherit due to time-offset columns', + ); + } + + // Comprehensive debug logging + console.group('๐Ÿ” BigNumberTotal transformProps - COMPREHENSIVE DEBUG'); + console.log('๐Ÿ“Š Input Data Analysis:', { + chartPropsKeys: Object.keys(chartProps), + hasQueriesData: !!queriesData, + queriesDataLength: queriesData?.length, + hasFormData: !!formData, + metricName, + bigNumber, + bigNumberType: typeof bigNumber, + }); + + console.log('๐Ÿ“‹ FormData Comprehensive Analysis:', { + hasTimeCompare: 'time_compare' in formData, + timeCompareValue: formData.time_compare, + timeCompareType: typeof formData.time_compare, + hasExtraFormData: 'extra_form_data' in formData, + extraFormDataKeys: formData.extra_form_data + ? Object.keys(formData.extra_form_data) + : [], + extraFormDataTimeCompare: (formData.extra_form_data as any)?.time_compare, + customFormDataTimeCompare: ( + formData.extra_form_data?.custom_form_data as any + )?.time_compare, + allFormDataKeys: Object.keys(formData), + hasTimeOffsetColumns, + finalTimeCompare: timeCompare, + finalTimeCompareType: typeof timeCompare, + }); + + console.log('๐Ÿ—‚๏ธ Query Data Analysis:', { + firstQueryData: queriesData[0], + colnames: queriesData[0]?.colnames, + hasData: !!queriesData[0]?.data, + dataLength: queriesData[0]?.data?.length, + firstRow: queriesData[0]?.data?.[0], + hasTimeOffsetColumns, + timeOffsetColumns: queriesData[0]?.colnames?.filter( + (col: string) => col.includes('__') && col !== metricName, + ), + }); + console.groupEnd(); + + // Check for time-offset columns in the single query + const timeOffsetColumns = + queriesData[0]?.colnames?.filter( + (col: string) => col.includes('__') && col !== metricName, + ) || []; + + // Debug logging + console.log('BigNumberTotal transformProps - Debug Info:', { + queriesDataLength: queriesData.length, + timeCompare, + extraFormData: formData.extra_form_data, + customFormData: formData.extra_form_data?.custom_form_data, + hasComparisonData: timeOffsetColumns.length > 0, + timeOffsetColumns, + currentDataStructure: queriesData[0], + metricName, + bigNumber, + }); + + console.log('BigNumberTotal transformProps - Current Period Data Analysis:', { + hasData: !!queriesData[0].data, + dataLength: queriesData[0].data?.length || 0, + dataType: typeof queriesData[0].data, + isArray: Array.isArray(queriesData[0].data), + firstRow: queriesData[0].data?.[0], + allRows: queriesData[0].data, + colnames: queriesData[0].colnames, + coltypes: queriesData[0].coltypes, + hasMetricColumn: queriesData[0].colnames?.includes(metricName), + metricColumnIndex: queriesData[0].colnames?.indexOf(metricName), + metricColumnType: + queriesData[0].coltypes?.[ + queriesData[0].colnames?.indexOf(metricName) || -1 + ] || null, + }); + + if (queriesData.length > 1) { + console.log( + 'BigNumberTotal transformProps - Comparison Period Data Analysis:', + { + hasData: !!queriesData[1].data, + dataLength: queriesData[1].data?.length || 0, + dataType: typeof queriesData[1].data, + isArray: Array.isArray(queriesData[1].data), + firstRow: queriesData[1].data?.[0], + allRows: queriesData[1].data, + colnames: queriesData[1].colnames, + coltypes: queriesData[1].coltypes, + hasMetricColumn: queriesData[1].colnames?.includes(metricName), + metricColumnIndex: queriesData[1].colnames?.indexOf(metricName), + metricColumnType: + queriesData[1].coltypes?.[ + queriesData[1].colnames?.indexOf(metricName) || -1 + ] || null, + }, + ); + } + + // With single-query approach, we need to look for time-offset data in the same result + console.group('โš™๏ธ BigNumberTotal transformProps - COMPARISON PROCESSING'); + + console.log('๐Ÿ”„ Processing conditions:', { + hasQueriesData: queriesData.length > 0, + timeCompare, + timeCompareValid: timeCompare && timeCompare !== 'NoComparison', + willProcess: + queriesData.length > 0 && timeCompare && timeCompare !== 'NoComparison', + }); + + if (queriesData.length > 0 && timeCompare && timeCompare !== 'NoComparison') { + console.log('โœ… Starting time comparison data processing...'); + + const queryData = queriesData[0].data; + const queryColnames = queriesData[0].colnames || []; + + // Look for columns with time offset suffixes (e.g., "metric__1 day ago") + const timeOffsetColumns = queryColnames.filter( + (col: string) => col.includes('__') && col !== metricName, + ); + + console.log('๐Ÿ“‹ Time offset analysis:', { + timeOffsetColumns, + metricName, + allColumns: queryColnames, + hasTimeOffsetColumns: timeOffsetColumns.length > 0, + queryDataLength: queryData?.length, + firstRowData: queryData?.[0], + }); + + if (timeOffsetColumns.length > 0 && queryData && queryData.length > 0) { + // Find the first time offset column that contains data + for (const offsetCol of timeOffsetColumns) { + const rawValue = queryData[0][offsetCol]; + console.log( + 'BigNumberTotal transformProps - Processing offset column:', + { + offsetCol, + rawValue, + rawValueType: typeof rawValue, + }, + ); + + if (rawValue !== null && rawValue !== undefined) { + previousPeriodValue = parseMetricValue(rawValue); + console.log( + 'BigNumberTotal transformProps - Parsed previousPeriodValue:', + previousPeriodValue, + ); + + if (previousPeriodValue !== null) { + // Handle special cases + if (previousPeriodValue === 0) { + if (bigNumber === null || bigNumber === 0) { + // Both values are 0 or current is null - no change or neutral + percentageChange = 0; + comparisonIndicator = 'neutral'; + } else if (bigNumber > 0) { + // Previous was 0, now positive - infinite growth, treat as positive + percentageChange = 1; // 100% change as maximum + comparisonIndicator = 'positive'; + } else { + // Previous was 0, now negative - treat as negative + percentageChange = -1; // -100% change as minimum + comparisonIndicator = 'negative'; + } + } else if (bigNumber === null || bigNumber === 0) { + // Current value is null or 0 but previous had value - complete loss + percentageChange = -1; // -100% change (complete loss) + comparisonIndicator = 'negative'; + } else { + // Normal calculation when both values are non-zero + percentageChange = + (bigNumber - previousPeriodValue) / + Math.abs(previousPeriodValue); + + if (percentageChange > 0) { + comparisonIndicator = 'positive'; + } else if (percentageChange < 0) { + comparisonIndicator = 'negative'; + } else { + comparisonIndicator = 'neutral'; + } + } + + console.log( + 'BigNumberTotal transformProps - Percentage change calculation:', + { + bigNumber, + previousPeriodValue, + difference: (bigNumber || 0) - previousPeriodValue, + absolutePrevious: Math.abs(previousPeriodValue), + percentageChange, + comparisonIndicator, + }, + ); + console.log( + 'BigNumberTotal transformProps - Comparison indicator set to:', + comparisonIndicator, + ); + break; // Found valid comparison data, exit loop + } else { + console.log( + 'BigNumberTotal transformProps - Cannot calculate percentage change:', + { + bigNumber, + previousPeriodValue, + reason: + bigNumber === null + ? 'bigNumber is null' + : previousPeriodValue === null + ? 'previousPeriodValue is null' + : previousPeriodValue === 0 + ? 'previousPeriodValue is 0' + : 'unknown', + }, + ); + } + } + } + } else { + console.log( + 'BigNumberTotal transformProps - No time offset columns or data available', + ); + } + } else { + console.log('โŒ Skipping comparison processing:', { + reason: + queriesData.length === 0 + ? 'No query data' + : !timeCompare + ? 'No time comparison' + : timeCompare === 'NoComparison' + ? 'NoComparison selected' + : 'unknown', + }); + } + + console.log('๐ŸŽฏ FINAL COMPARISON RESULTS:', { + previousPeriodValue, + previousPeriodValueType: typeof previousPeriodValue, + percentageChange, + percentageChangeType: typeof percentageChange, + comparisonIndicator, + comparisonIndicatorType: typeof comparisonIndicator, + hasValidComparison: + percentageChange !== undefined && comparisonIndicator !== undefined, + comparisonCalculation: + previousPeriodValue !== null && bigNumber !== null + ? { + current: bigNumber, + previous: previousPeriodValue, + difference: (bigNumber as number) - previousPeriodValue, + calculation: `(${bigNumber} - ${previousPeriodValue}) / ${Math.abs( + previousPeriodValue, + )} = ${percentageChange}`, + } + : 'Cannot calculate', + }); + console.groupEnd(); + let metricEntry: Metric | undefined; if (chartProps.datasource?.metrics) { metricEntry = chartProps.datasource.metrics.find( @@ -99,7 +386,7 @@ export default function transformProps( getColorFormatters(conditionalFormatting, data, false) ?? defaultColorFormatters; - return { + const returnProps = { width, height, bigNumber, @@ -110,5 +397,30 @@ export default function transformProps( onContextMenu, refs, colorThresholdFormatters, + previousPeriodValue, + percentageChange, + comparisonIndicator, }; + + console.group('๐Ÿš€ BigNumberTotal transformProps - FINAL RETURN PROPS'); + console.log('๐Ÿ“ฆ Returning props to BigNumberViz:', { + width, + height, + bigNumber, + bigNumberType: typeof bigNumber, + subheader: formattedSubheader, + previousPeriodValue, + previousPeriodValueType: typeof previousPeriodValue, + percentageChange, + percentageChangeType: typeof percentageChange, + comparisonIndicator, + comparisonIndicatorType: typeof comparisonIndicator, + hasComparison: + percentageChange !== undefined && comparisonIndicator !== undefined, + formData: formData.time_compare, + returnPropsKeys: Object.keys(returnProps), + }); + console.groupEnd(); + + return returnProps; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx index 99863e36d94a..d1c438de32e8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx @@ -26,7 +26,6 @@ import { BRAND_COLOR, styled, BinaryQueryObjectFilterClause, - getCurrencySymbol, } from '@superset-ui/core'; import Echart from '../components/Echart'; import { BigNumberVizProps } from './types'; @@ -59,6 +58,91 @@ class BigNumberVis extends PureComponent { timeRangeFixed: false, }; + componentDidMount() { + // Comprehensive debug logging for component mounting + console.group('๐ŸŽฏ BigNumberViz componentDidMount - DATA ARRIVAL CHECK'); + console.log('๐Ÿ“ฆ All Props Received:', { + allPropKeys: Object.keys(this.props), + propsCount: Object.keys(this.props).length, + }); + + console.log('๐Ÿ”ข Big Number Data:', { + bigNumber: this.props.bigNumber, + bigNumberType: typeof this.props.bigNumber, + hasBigNumber: + this.props.bigNumber !== undefined && this.props.bigNumber !== null, + }); + + console.log('๐Ÿ“Š Comparison Data Arrival Check:', { + percentageChange: this.props.percentageChange, + percentageChangeType: typeof this.props.percentageChange, + hasPercentageChange: this.props.percentageChange !== undefined, + comparisonIndicator: this.props.comparisonIndicator, + comparisonIndicatorType: typeof this.props.comparisonIndicator, + hasComparisonIndicator: this.props.comparisonIndicator !== undefined, + previousPeriodValue: (this.props as any).previousPeriodValue, + hasPreviousPeriodValue: + (this.props as any).previousPeriodValue !== undefined, + }); + + console.log('๐Ÿ“‹ Form Data Check:', { + hasFormData: !!this.props.formData, + formDataKeys: this.props.formData ? Object.keys(this.props.formData) : [], + timeCompare: this.props.formData?.time_compare, + extraFormData: this.props.formData?.extra_form_data, + extraFormDataKeys: this.props.formData?.extra_form_data + ? Object.keys(this.props.formData.extra_form_data) + : [], + extraTimeCompare: (this.props.formData?.extra_form_data as any) + ?.time_compare, + customFormData: this.props.formData?.extra_form_data?.custom_form_data, + customTimeCompare: ( + this.props.formData?.extra_form_data?.custom_form_data as any + )?.time_compare, + }); + + console.log('๐ŸŽฏ Comparison Ready Status:', { + hasAllRequiredData: + this.props.percentageChange !== undefined && + this.props.comparisonIndicator !== undefined, + shouldRenderIndicator: + this.props.percentageChange !== undefined && + this.props.comparisonIndicator !== undefined, + missingData: { + percentageChange: this.props.percentageChange === undefined, + comparisonIndicator: this.props.comparisonIndicator === undefined, + }, + }); + + console.groupEnd(); + } + + componentDidUpdate(prevProps: BigNumberVizProps) { + // Log when props change to track updates + const currentComparison = { + percentageChange: this.props.percentageChange, + comparisonIndicator: this.props.comparisonIndicator, + }; + + const prevComparison = { + percentageChange: prevProps.percentageChange, + comparisonIndicator: prevProps.comparisonIndicator, + }; + + if (JSON.stringify(currentComparison) !== JSON.stringify(prevComparison)) { + console.group('๐Ÿ”„ BigNumberViz componentDidUpdate - PROPS CHANGED'); + console.log('Previous comparison props:', prevComparison); + console.log('New comparison props:', currentComparison); + console.log('Change detected:', { + percentageChangeChanged: + this.props.percentageChange !== prevProps.percentageChange, + comparisonIndicatorChanged: + this.props.comparisonIndicator !== prevProps.comparisonIndicator, + }); + console.groupEnd(); + } + } + getClassName() { const { className, showTrendLine, bigNumberFallback } = this.props; const names = `superset-legacy-chart-big-number ${className} ${ @@ -289,7 +373,7 @@ class BigNumberVis extends PureComponent { const allTextHeight = height - chartHeight; return ( -
+
{this.renderFallbackWarning()} {this.renderKicker( @@ -312,7 +396,7 @@ class BigNumberVis extends PureComponent { } return ( -
+
{this.renderFallbackWarning()} {this.renderKicker((kickerFontSize || 0) * height)} {this.renderHeader(Math.ceil(headerFontSize * height))} @@ -377,5 +461,23 @@ export default styled(BigNumberVis)` opacity: ${theme.opacity.mediumHeavy}; } } + + .comparison-indicator { + @keyframes fadeInScale { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } + } + + &:hover { + transform: scale(1.05); + transition: transform 0.2s ease; + } + } `} `; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts index 7a0ba462b88b..d3888d307ac2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/buildQuery.ts @@ -30,22 +30,199 @@ import { rollingWindowOperator, } from '@superset-ui/chart-controls'; +/** + * Detect time period from time range string + * Examples: "Last 7 days" -> "7 days ago", "Last 1 month" -> "1 month ago" + */ +function detectTimePeriod(timeRange: string): string | null { + const match = timeRange.match(/last\s+(\d+)\s+(day|week|month|year)s?/i); + if (match) { + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + // Handle singular vs plural + const singularUnit = value === 1 ? unit : `${unit}s`; + return `${value} ${singularUnit} ago`; + } + + // Handle special cases + const lowerTimeRange = timeRange.toLowerCase(); + if (lowerTimeRange.includes('this week')) return '1 week ago'; + if (lowerTimeRange.includes('this month')) return '1 month ago'; + if (lowerTimeRange.includes('this year')) return '1 year ago'; + + return null; +} + +/** + * Calculate time period from since/until dates + * Example: since="2023-08-23", until="2023-08-26" -> "4 days ago" + */ +function calculatePeriodFromDates(since: string, until: string): string | null { + try { + const sinceDate = new Date(since); + const untilDate = new Date(until); + const diffTime = Math.abs(untilDate.getTime() - sinceDate.getTime()); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 to include both dates + + if (diffDays === 1) return '1 day ago'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`; + } + if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return months === 1 ? '1 month ago' : `${months} months ago`; + } + const years = Math.floor(diffDays / 365); + return years === 1 ? '1 year ago' : `${years} years ago`; + } catch (error) { + console.warn('Error calculating period from dates:', error); + return null; + } +} + export default function buildQuery(formData: QueryFormData) { - return buildQueryContext(formData, baseQueryObject => [ + // Debug logging for buildQuery + console.log('BigNumberWithTrendline buildQuery - Input formData:', { + hasExtraFormData: !!formData.extra_form_data, + extraFormData: formData.extra_form_data, + customFormData: formData.extra_form_data?.custom_form_data, + timeCompare: formData.time_compare, + timeCompareExtra: (formData.extra_form_data?.custom_form_data as any) + ?.time_compare, + timeCompareDirect: (formData.extra_form_data as any)?.time_compare, + metrics: formData.metrics, + datasource: formData.datasource, + xAxis: formData.x_axis, + timeGrain: formData.time_grain_sqla, + timeRange: formData.time_range, + since: formData.since, + until: formData.until, + granularity: formData.granularity, + filters: formData.filters, + adhocFilters: formData.adhoc_filters, + }); + + const buildQuery = (baseQueryObject: any) => { + console.log( + 'BigNumberWithTrendline buildQuery - baseQueryObject:', + baseQueryObject, + ); + + // Use the proper Superset single-query approach for time comparison + // This ensures that formData.time_compare is preserved and passed to transformProps + const time_offsets = (() => { + const timeCompare = + formData.time_compare || + (formData.extra_form_data as any)?.time_compare; + + // Convert string time comparison to array format for transformProps + if (timeCompare && timeCompare !== 'NoComparison') { + if (timeCompare === 'inherit') { + // Enhanced inherit logic: detect actual time period and apply same offset + const timeRange = formData.time_range; + const { since } = formData; + const { until } = formData; + + console.log( + 'BigNumberWithTrendline buildQuery - Inherit logic analysis:', + { + timeRange, + since, + until, + hasTimeRange: !!timeRange, + hasSinceUntil: !!(since && until), + }, + ); + + if (timeRange && typeof timeRange === 'string') { + // Parse time range string to detect period + const period = detectTimePeriod(timeRange); + if (period) { + console.log( + 'BigNumberWithTrendline buildQuery - Detected period from timeRange:', + period, + ); + return [period]; + } + } + + if (since && until) { + // Calculate period from since/until dates + const period = calculatePeriodFromDates(since, until); + if (period) { + console.log( + 'BigNumberWithTrendline buildQuery - Calculated period from dates:', + period, + ); + return [period]; + } + } + + // Fallback to default + console.log( + 'BigNumberWithTrendline buildQuery - Using fallback period: 1 day ago', + ); + return ['1 day ago']; + } + if (timeCompare === 'custom') { + // For custom, use the time_compare_value + const customValue = + formData.time_compare_value || + (formData.extra_form_data as any)?.time_compare_value; + return customValue ? [customValue] : []; + } + // For direct string values like "1 day ago", "1 week ago", etc. + return Array.isArray(timeCompare) ? timeCompare : [timeCompare]; + } + return []; + })(); + + return [ + { + ...baseQueryObject, + columns: [ + ...(isXAxisSet(formData) + ? ensureIsArray(getXAxisColumn(formData)) + : []), + ], + ...(isXAxisSet(formData) ? {} : { is_timeseries: true }), + post_processing: [ + pivotOperator(formData, baseQueryObject), + rollingWindowOperator(formData, baseQueryObject), + resampleOperator(formData, baseQueryObject), + flattenOperator(formData, baseQueryObject), + ], + ...(time_offsets.length > 0 ? { time_offsets } : {}), + }, + ]; + }; + + // Ensure time_compare is preserved on the root formData object + const timeCompare = + formData.time_compare || (formData.extra_form_data as any)?.time_compare; + + if (timeCompare && !formData.time_compare) { + // eslint-disable-next-line no-param-reassign + formData.time_compare = timeCompare; + console.log( + 'BigNumberWithTrendline buildQuery - Setting time_compare on root formData:', + timeCompare, + ); + } + + const result = buildQueryContext(formData, buildQuery); + console.log( + 'BigNumberWithTrendline buildQuery - Final result from buildQueryContext:', { - ...baseQueryObject, - columns: [ - ...(isXAxisSet(formData) - ? ensureIsArray(getXAxisColumn(formData)) - : []), - ], - ...(isXAxisSet(formData) ? {} : { is_timeseries: true }), - post_processing: [ - pivotOperator(formData, baseQueryObject), - rollingWindowOperator(formData, baseQueryObject), - resampleOperator(formData, baseQueryObject), - flattenOperator(formData, baseQueryObject), - ], + result, + hasQueries: !!result.queries, + queriesLength: result.queries?.length, + formData: result.form_data, + timeCompare: result.form_data?.time_compare, }, - ]); + ); + + return result; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx index ba47e78a5bd0..70276e4dd7a0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/controlPanel.tsx @@ -24,7 +24,9 @@ import { D3_TIME_FORMAT_OPTIONS, getStandardizedControls, temporalColumnMixin, + sections, } from '@superset-ui/chart-controls'; + import { headerFontSize, subheaderFontSize } from '../sharedControls'; const config: ControlPanelConfig = { @@ -58,7 +60,9 @@ const config: ControlPanelConfig = { type: 'HiddenControl', label: t('URL parameters'), hidden: true, - description: t('Extra parameters for use in jinja templated queries'), + description: t( + 'Extra parameters for use in jinja templated queries', + ), }, }, ], @@ -76,6 +80,7 @@ const config: ControlPanelConfig = { ['adhoc_filters'], ], }, + sections.timeComparisonControls({ multi: false }), { label: t('Options'), tabOverride: 'data', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts index 846930fe7a19..3cfe605fd3b9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts @@ -92,11 +92,12 @@ export default function transformProps( const xAxisLabel = getXAxisLabel(rawFormData) as string; let trendLineData: TimeSeriesDatum[] | undefined; - let percentChange = 0; + let percentageChange: number | undefined; let bigNumber = data.length === 0 ? null : data[0][metricName]; let timestamp = data.length === 0 ? null : data[0][xAxisLabel]; let bigNumberFallback; + // Process the main data first const metricColtypeIndex = colnames.findIndex(name => name === metricName); const metricColtype = metricColtypeIndex > -1 ? coltypes[metricColtypeIndex] : null; @@ -122,11 +123,11 @@ export default function transformProps( const compareValue = sortedData[compareIndex][1]; // compare values must both be non-nulls if (bigNumber !== null && compareValue !== null) { - percentChange = compareValue + percentageChange = compareValue ? (bigNumber - compareValue) / Math.abs(compareValue) : 0; formattedSubheader = `${formatPercentChange( - percentChange, + percentageChange, )} ${compareSuffix}`; } } @@ -136,10 +137,246 @@ export default function transformProps( trendLineData = showTrendLine ? sortedData : undefined; } + // Handle comparison data if available and time comparison is enabled + let previousPeriodValue: number | null = null; + let comparisonIndicator: 'positive' | 'negative' | 'neutral' | undefined; + + // Handle time comparison - check all possible locations where time_compare might be stored + let timeCompare = + formData.time_compare || + (formData.extra_form_data?.custom_form_data as any)?.time_compare || + (formData.extra_form_data as any)?.time_compare; + + // If we have time-offset columns but no timeCompare detected, force it to 'inherit' + // This handles cases where the UI selection isn't properly propagated to formData + const hasTimeOffsetColumns = queriesData[0]?.colnames?.some( + (col: string) => col.includes('__') && col !== metricName, + ); + + if (!timeCompare && hasTimeOffsetColumns) { + timeCompare = 'inherit'; + console.log( + 'BigNumberWithTrendline transformProps - Forcing timeCompare to inherit due to time-offset columns', + ); + } + + // Additional debugging to understand formData structure + console.log( + 'BigNumberWithTrendline transformProps - Full formData analysis:', + { + hasTimeCompare: 'time_compare' in formData, + timeCompareValue: formData.time_compare, + hasExtraFormData: 'extra_form_data' in formData, + extraFormDataKeys: formData.extra_form_data + ? Object.keys(formData.extra_form_data) + : [], + extraFormDataTimeCompare: (formData.extra_form_data as any)?.time_compare, + customFormDataTimeCompare: ( + formData.extra_form_data?.custom_form_data as any + )?.time_compare, + allFormDataKeys: Object.keys(formData), + hasTimeOffsetColumns, + finalTimeCompare: timeCompare, + fullFormData: formData, + }, + ); + + // Check for time-offset columns in the single query + const timeOffsetColumns = + queriesData[0]?.colnames?.filter( + (col: string) => col.includes('__') && col !== metricName, + ) || []; + + // Debug logging + console.log('BigNumberWithTrendline transformProps - Debug Info:', { + queriesDataLength: queriesData.length, + timeCompare, + extraFormData: formData.extra_form_data, + customFormData: formData.extra_form_data?.custom_form_data, + hasComparisonData: timeOffsetColumns.length > 0, + timeOffsetColumns, + currentDataStructure: queriesData[0], + metricName, + bigNumber, + xAxisLabel, + }); + + if (queriesData.length > 1) { + console.log( + 'BigNumberWithTrendline transformProps - Comparison Period Data Analysis:', + { + hasData: !!queriesData[1].data, + dataLength: queriesData[1].data?.length || 0, + dataType: typeof queriesData[1].data, + isArray: Array.isArray(queriesData[1].data), + firstRow: queriesData[1].data?.[0], + allRows: queriesData[1].data, + colnames: queriesData[1].colnames, + coltypes: queriesData[1].coltypes, + hasMetricColumn: queriesData[1].colnames?.includes(metricName), + metricColumnIndex: queriesData[1].colnames?.indexOf(metricName), + metricColumnType: + queriesData[1].coltypes?.[ + queriesData[1].colnames?.indexOf(metricName) || -1 + ] || null, + }, + ); + } + + // With single-query approach, we need to look for time-offset data in the same result + if (queriesData.length > 0 && timeCompare && timeCompare !== 'NoComparison') { + console.log( + 'BigNumberWithTrendline transformProps - Processing time comparison data...', + ); + + const queryData = queriesData[0].data; + const queryColnames = queriesData[0].colnames || []; + + // Look for columns with time offset suffixes (e.g., "metric__1 day ago") + const timeOffsetColumns = queryColnames.filter( + (col: string) => col.includes('__') && col !== metricName, + ); + + console.log( + 'BigNumberWithTrendline transformProps - Time offset columns found:', + { + timeOffsetColumns, + metricName, + allColumns: queryColnames, + }, + ); + + if (timeOffsetColumns.length > 0 && queryData && queryData.length > 0) { + // Find the first time offset column that contains data + for (const offsetCol of timeOffsetColumns) { + const rawValue = queryData[0][offsetCol]; + console.log( + 'BigNumberWithTrendline transformProps - Processing offset column:', + { + offsetCol, + rawValue, + rawValueType: typeof rawValue, + }, + ); + + if ( + rawValue !== null && + rawValue !== undefined && + typeof rawValue === 'number' + ) { + previousPeriodValue = parseMetricValue(rawValue); + console.log( + 'BigNumberWithTrendline transformProps - Parsed previousPeriodValue:', + previousPeriodValue, + ); + + if (bigNumber !== null && previousPeriodValue !== null) { + const bigNumberValue = bigNumber as number; + let calculatedPercentageChange: number; + + // Handle special cases + if (previousPeriodValue === 0) { + if (bigNumberValue === 0) { + // Both values are 0 - no change + calculatedPercentageChange = 0; + comparisonIndicator = 'neutral'; + } else if (bigNumberValue > 0) { + // Previous was 0, now positive - infinite growth, treat as positive + calculatedPercentageChange = 1; // 100% change as maximum + comparisonIndicator = 'positive'; + } else { + // Previous was 0, now negative - treat as negative + calculatedPercentageChange = -1; // -100% change as minimum + comparisonIndicator = 'negative'; + } + } else if (bigNumberValue === 0) { + // Current is 0 but previous was not 0 - complete loss (-100%) + calculatedPercentageChange = -1; // -100% change (complete loss) + comparisonIndicator = 'negative'; + } else { + // Normal calculation when both values are non-zero + calculatedPercentageChange = + (bigNumberValue - previousPeriodValue) / + Math.abs(previousPeriodValue); + + if (calculatedPercentageChange > 0) { + comparisonIndicator = 'positive'; + } else if (calculatedPercentageChange < 0) { + comparisonIndicator = 'negative'; + } else { + comparisonIndicator = 'neutral'; + } + } + + percentageChange = calculatedPercentageChange; + console.log( + 'BigNumberWithTrendline transformProps - Percentage change calculation:', + { + bigNumber, + previousPeriodValue, + difference: bigNumberValue - previousPeriodValue, + absolutePrevious: Math.abs(previousPeriodValue), + percentageChange: calculatedPercentageChange, + comparisonIndicator, + }, + ); + console.log( + 'BigNumberWithTrendline transformProps - Comparison indicator set to:', + comparisonIndicator, + ); + break; // Found valid comparison data, exit loop + } else { + console.log( + 'BigNumberWithTrendline transformProps - Cannot calculate percentage change:', + { + bigNumber, + previousPeriodValue, + reason: + bigNumber === null + ? 'bigNumber is null' + : previousPeriodValue === null + ? 'previousPeriodValue is null' + : previousPeriodValue === 0 + ? 'previousPeriodValue is 0' + : 'unknown', + }, + ); + } + } else { + console.log( + 'BigNumberWithTrendline transformProps - Raw comparison value is not a valid number:', + { + rawValue, + type: typeof rawValue, + }, + ); + } + } + } else { + console.log( + 'BigNumberWithTrendline transformProps - No time offset columns or data available', + ); + } + } else { + console.log( + 'BigNumberWithTrendline transformProps - Skipping comparison processing:', + { + reason: + queriesData.length === 0 + ? 'No query data' + : !timeCompare + ? 'No time comparison' + : timeCompare === 'NoComparison' + ? 'NoComparison selected' + : 'unknown', + }, + ); + } + let className = ''; - if (percentChange > 0) { + if (percentageChange && percentageChange > 0) { className = 'positive'; - } else if (percentChange < 0) { + } else if (percentageChange && percentageChange < 0) { className = 'negative'; } @@ -276,5 +513,8 @@ export default function transformProps( onContextMenu, xValueFormatter: formatTime, refs, + previousPeriodValue, + percentageChange, + comparisonIndicator, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts index 7c4908adac1c..231df297623e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts @@ -96,4 +96,9 @@ export type BigNumberVizProps = { formData?: BigNumberWithTrendlineFormData; refs: Refs; colorThresholdFormatters?: ColorFormatters; + // New comparison properties + previousPeriodValue?: number | null; + percentageChange?: number; + comparisonIndicator?: 'positive' | 'negative' | 'neutral'; + comparisonPeriodText?: string; }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberTotal.buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberTotal.buildQuery.test.ts new file mode 100644 index 000000000000..c0a1b572b807 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberTotal.buildQuery.test.ts @@ -0,0 +1,373 @@ +/** + * 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 { QueryFormData, SqlaFormData } from '@superset-ui/core'; +import buildQuery from '../../src/BigNumber/BigNumberTotal/buildQuery'; + +describe('BigNumberTotal buildQuery with Time Comparison', () => { + const baseFormData: QueryFormData = { + metric: 'value', + viz_type: 'big_number_total', + datasource: 'test_datasource', + }; + + describe('Basic Query Building', () => { + it('should build query without time comparison', () => { + const formData = { ...baseFormData }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + + it('should build query with basic time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + }); + + describe('Time Comparison Types', () => { + it('should work with "inherit" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); // inherit resolves to "1 day ago" + }); + + it('should detect time period from time range string - Last 4 days', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + time_range: 'Last 4 days', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['4 days ago']); + }); + + it('should detect time period from time range string - Last 1 week', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + time_range: 'Last 1 week', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 week ago']); + }); + + it('should detect time period from time range string - Last 2 months', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + time_range: 'Last 2 months', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', [ + '2 months ago', + ]); + }); + + it('should calculate period from since/until dates - 4 days', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + since: '2023-08-23', + until: '2023-08-26', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['4 days ago']); + }); + + it('should calculate period from since/until dates - 1 week', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + since: '2023-08-20', + until: '2023-08-26', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 week ago']); + }); + + it('should handle special time range cases - today', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + time_range: 'Today', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 day ago']); + }); + + it('should handle special time range cases - this week', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + time_range: 'This week', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 week ago']); + }); + + it('should work with "1 day ago" time comparison', () => { + const formData = { + ...baseFormData, + time_compare: '1 day ago', + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + + it('should work with "1 week ago" time comparison', () => { + const formData = { + ...baseFormData, + time_compare: '1 week ago', + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 week ago'); + }); + + it('should work with "1 month ago" time comparison', () => { + const formData = { + ...baseFormData, + time_compare: '1 month ago', + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 month ago'); + }); + + it('should work with "1 year ago" time comparison', () => { + const formData = { + ...baseFormData, + time_compare: '1 year ago', + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 year ago'); + }); + + it('should work with "custom" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'custom', + time_compare_value: '7 days ago', + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('7 days ago'); // custom uses time_compare_value + }); + }); + + describe('Edge Cases', () => { + it('should handle undefined time comparison', () => { + const formData = { + ...baseFormData, + time_compare: undefined, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + + it('should handle null time comparison', () => { + const formData = { + ...baseFormData, + time_compare: null, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + + it('should handle empty extra_form_data', () => { + const formData = { + ...baseFormData, + extra_form_data: {}, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + + it('should handle missing extra_form_data', () => { + const formData = { + ...baseFormData, + extra_form_data: undefined, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + }); + + describe('Query Structure', () => { + it('should maintain metric consistency across queries', () => { + const formData = { + ...baseFormData, + metric: 'sales', + time_compare: '1 day ago', + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('sales'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + + it('should maintain datasource consistency across queries', () => { + const formData: SqlaFormData = { + ...baseFormData, + datasource: 'custom_datasource', + time_compare: '1 day ago', + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + // Note: buildQueryContext doesn't preserve datasource in query objects + // The datasource is handled at the form data level + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + + it('should preserve additional form data properties', () => { + const formData: SqlaFormData = { + ...baseFormData, + filters: ['custom_filter'], + time_compare: '1 day ago', + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + // Note: buildQueryContext doesn't preserve filters in query objects + // The filters are handled at the form data level + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + }); + + describe('Integration with Superset Core', () => { + it('should use getComparisonInfo for time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'inherit', + }; + const result = buildQuery(formData); + + // The second query should have comparison-specific properties + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toBeDefined(); + + // Verify that the comparison query is properly structured + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); // inherit resolves to "1 day ago" + }); + + it('should handle complex time comparison scenarios', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_compare: 'custom', + time_compare_value: 'custom_range', + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toBeDefined(); + + // Both should maintain basic properties + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('custom_range'); // custom uses time_compare_value + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberTotal.transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberTotal.transformProps.test.ts new file mode 100644 index 000000000000..ba695c9a192b --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberTotal.transformProps.test.ts @@ -0,0 +1,522 @@ +/** + * 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 { + DatasourceType, + supersetTheme, + TimeGranularity, + QueryFormData, + QueryData, +} from '@superset-ui/core'; +import transformProps from '../../src/BigNumber/BigNumberTotal/transformProps'; +import { BigNumberVizProps } from '../../src/BigNumber/types'; + +const formData: QueryFormData = { + metric: 'value', + viz_type: 'big_number_total', + yAxisFormat: '.3s', + datasource: 'test_datasource', + headerFontSize: 0.3, + subheaderFontSize: 0.125, + subheader: 'Test subheader', + forceTimestampFormatting: false, + currencyFormat: undefined, +}; + +function generateProps( + data: any[], + comparisonData: any[] = [], + extraFormData = {}, + extraQueryData: any = {}, +): any { + const queriesData = [ + { + data, + colnames: ['value'], + coltypes: ['DOUBLE'], + ...extraQueryData, + }, + ]; + + // Add comparison data if provided - now as time-offset columns in the same query + if (comparisonData.length > 0) { + // Get the time comparison value from extraFormData + const timeCompare = + (extraFormData as any)?.extra_form_data?.time_compare || + (extraFormData as any)?.time_compare; + + if (timeCompare && timeCompare !== 'NoComparison') { + // Resolve the actual time offset string + let resolvedTimeOffset: string; + if (timeCompare === 'inherit') { + resolvedTimeOffset = '1 day ago'; + } else if (timeCompare === 'custom') { + resolvedTimeOffset = + (extraFormData as any)?.time_compare_value || 'custom_range'; + } else { + resolvedTimeOffset = timeCompare; + } + + // Get the metric name from the formData (default to 'value' if not specified) + const metricName = (extraFormData as any)?.metric || 'value'; + + // Create time-offset column name using the actual metric name + const timeOffsetColumn = `${metricName}__${resolvedTimeOffset}`; + + // Update the first query to include both current and comparison data + queriesData[0] = { + data: data.map((row, index) => ({ + ...row, + [timeOffsetColumn]: comparisonData[index]?.[metricName] ?? null, + })), + colnames: [metricName, timeOffsetColumn], + coltypes: ['DOUBLE', 'DOUBLE'], + ...extraQueryData, + }; + } + } + + return { + width: 200, + height: 200, + formData: { ...formData, ...extraFormData }, + queriesData, + datasource: { + type: DatasourceType.Table, + columns: [], + metrics: [], + verboseMap: {}, + columnFormats: {}, + currencyFormats: {}, + }, + theme: supersetTheme, + rawFormData: { ...formData, ...extraFormData }, + hooks: {}, + initialValues: {}, + }; +} + +describe('BigNumberTotal transformProps with Time Comparison', () => { + describe('Basic Functionality', () => { + it('should transform basic props correctly without time comparison', () => { + const props = generateProps([{ value: 1234567.89 }]); + const result = transformProps(props); + + expect(result).toMatchObject({ + width: 200, + height: 200, + bigNumber: 1234567.89, + }); + }); + + it('should handle empty data gracefully', () => { + const props = generateProps([]); + const result = transformProps(props); + + expect(result).toMatchObject({ + width: 200, + height: 200, + bigNumber: null, + }); + }); + }); + + describe('Time Comparison - Positive Changes', () => { + it('should handle 50% increase correctly', () => { + const props = generateProps( + [{ value: 1500 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1500, + previousPeriodValue: 1000, + percentageChange: 0.5, // (1500 - 1000) / 1000 = 0.5 + comparisonIndicator: 'positive', + }); + }); + + it('should handle 100% increase correctly', () => { + const props = generateProps( + [{ value: 2000 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 2000, + previousPeriodValue: 1000, + percentageChange: 1.0, // (2000 - 1000) / 1000 = 1.0 + comparisonIndicator: 'positive', + }); + }); + + it('should handle large percentage increases', () => { + const props = generateProps( + [{ value: 10000 }], // current period + [{ value: 100 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 10000, + previousPeriodValue: 100, + percentageChange: 99, // (10000 - 100) / 100 = 99 + comparisonIndicator: 'positive', + }); + }); + }); + + describe('Time Comparison - Negative Changes', () => { + it('should handle 20% decrease correctly', () => { + const props = generateProps( + [{ value: 800 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 800, + previousPeriodValue: 1000, + percentageChange: -0.2, // (800 - 1000) / 1000 = -0.2 + comparisonIndicator: 'negative', + }); + }); + + it('should handle 50% decrease correctly', () => { + const props = generateProps( + [{ value: 500 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 500, + previousPeriodValue: 1000, + percentageChange: -0.5, // (500 - 1000) / 1000 = -0.5 + comparisonIndicator: 'negative', + }); + }); + + it('should handle 90% decrease correctly', () => { + const props = generateProps( + [{ value: 100 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 100, + previousPeriodValue: 1000, + percentageChange: -0.9, // (100 - 1000) / 1000 = -0.9 + comparisonIndicator: 'negative', + }); + }); + }); + + describe('Time Comparison - No Change', () => { + it('should handle no change correctly', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: 1000, + percentageChange: 0, // (1000 - 1000) / 1000 = 0 + comparisonIndicator: 'neutral', + }); + }); + + it('should handle very small changes as neutral', () => { + const props = generateProps( + [{ value: 1000.001 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000.001, + previousPeriodValue: 1000, + percentageChange: 9.999999999763531e-7, // Very small change + comparisonIndicator: 'positive', + }); + }); + }); + + describe('Time Comparison - Zero Value Handling', () => { + it('should handle zero current and zero previous as neutral', () => { + const props = generateProps( + [{ value: 0 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 0, + previousPeriodValue: 0, + percentageChange: 0, // Both zero = no change + comparisonIndicator: 'neutral', + }); + }); + + it('should handle zero previous with positive current as 100% increase', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: 0, + percentageChange: 1, // Treated as 100% increase + comparisonIndicator: 'positive', + }); + }); + + it('should handle zero previous with negative current as -100% decrease', () => { + const props = generateProps( + [{ value: -1000 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: -1000, + previousPeriodValue: 0, + percentageChange: -1, // Treated as -100% decrease + comparisonIndicator: 'negative', + }); + }); + + it('should handle zero current with positive previous as -100% decrease (complete loss)', () => { + const props = generateProps( + [{ value: 0 }], // current period + [{ value: 102942 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 0, + previousPeriodValue: 102942, + percentageChange: -1, // -100% decrease (complete loss) + comparisonIndicator: 'negative', + }); + }); + }); + + describe('Time Comparison - Edge Cases', () => { + it('should handle zero previous period value', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: 0, + percentageChange: 1, // Treated as 100% increase (0 to 1000) + comparisonIndicator: 'positive', + }); + }); + + it('should handle null previous period value', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: null }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + + it('should handle empty comparison data', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [], // empty comparison data + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + + it('should handle negative values correctly', () => { + const props = generateProps( + [{ value: -500 }], // current period + [{ value: -1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: -500, + previousPeriodValue: -1000, + percentageChange: 0.5, // (-500 - (-1000)) / 1000 = 0.5 + comparisonIndicator: 'positive', + }); + }); + }); + + describe('Time Comparison - Different Time Periods', () => { + it('should work with "inherit" time comparison', () => { + const props = generateProps( + [{ value: 2000 }], // current period + [{ value: 1500 }], // previous period + { extra_form_data: { time_compare: 'inherit' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 2000, + previousPeriodValue: 1500, + percentageChange: 0.3333333333333333, // (2000 - 1500) / 1500 + comparisonIndicator: 'positive', + }); + }); + + it('should work with "1 week ago" time comparison', () => { + const props = generateProps( + [{ value: 3000 }], // current period + [{ value: 2500 }], // previous period + { extra_form_data: { time_compare: '1 week ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 3000, + previousPeriodValue: 2500, + percentageChange: 0.2, // (3000 - 2500) / 2500 = 0.2 + comparisonIndicator: 'positive', + }); + }); + + it('should work with "1 month ago" time comparison', () => { + const props = generateProps( + [{ value: 4000 }], // current period + [{ value: 3500 }], // previous period + { extra_form_data: { time_compare: '1 month ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 4000, + previousPeriodValue: 3500, + percentageChange: 0.14285714285714285, // (4000 - 3500) / 3500 + comparisonIndicator: 'positive', + }); + }); + }); + + describe('No Time Comparison', () => { + it('should handle no time comparison correctly', () => { + const props = generateProps([{ value: 1000 }]); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + + it('should handle undefined time comparison', () => { + const props = generateProps([{ value: 1000 }], [{ value: 800 }], { + extra_form_data: { time_compare: undefined }, + }); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + }); + + describe('Data Format Handling', () => { + it('should handle different metric names', () => { + const customFormData = { + ...formData, + metric: 'sales', + extra_form_data: { time_compare: '1 day ago' }, + }; + const props = generateProps( + [{ sales: 1500 }], // current period + [{ sales: 1000 }], // previous period + customFormData, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1500, + previousPeriodValue: 1000, + percentageChange: 0.5, + comparisonIndicator: 'positive', + }); + }); + + it('should handle multiple data rows', () => { + const props = generateProps( + [{ value: 1500 }, { value: 1600 }], // current period + [{ value: 1000 }, { value: 1100 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + // Should use the first row for comparison + expect(result).toMatchObject({ + bigNumber: 1500, + previousPeriodValue: 1000, + percentageChange: 0.5, + comparisonIndicator: 'positive', + }); + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberViz.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberViz.test.tsx new file mode 100644 index 000000000000..0501096b24e2 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberViz.test.tsx @@ -0,0 +1,564 @@ +/** + * 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 { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { supersetTheme } from '@superset-ui/core'; +import BigNumberViz from '../../src/BigNumber/BigNumberViz'; +import { BigNumberVizProps } from '../../src/BigNumber/types'; + +// Mock the getNumberFormatter function +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + getNumberFormatter: jest.fn(() => (value: number) => `${value}%`), +})); + +const defaultProps: BigNumberVizProps = { + width: 200, + height: 200, + bigNumber: 1000, + className: 'test-class', + headerFontSize: 0.3, + subheaderFontSize: 0.125, + subheader: 'Test subheader', + formatNumber: (value: number) => value.toString(), + formatTime: (value: string) => value, + theme: supersetTheme, +}; + +describe('BigNumberViz with Time Comparison', () => { + describe('Basic Rendering', () => { + it('should render basic big number without time comparison', () => { + render(); + + expect(screen.getByText('1000%')).toBeInTheDocument(); + expect(screen.getByText('Test subheader')).toBeInTheDocument(); + }); + + it('should render with custom formatting', () => { + const customFormatNumber = (value: number) => `$${value}`; + render( + , + ); + + expect(screen.getByText('1000%')).toBeInTheDocument(); + }); + + it('should handle null big number', () => { + render(); + + expect(screen.getByText('0')).toBeInTheDocument(); + }); + }); + + describe('Time Comparison - Positive Changes', () => { + it('should render positive comparison indicator with green color and up arrow', () => { + const props = { + ...defaultProps, + percentageChange: 0.5, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + // Check that the comparison indicator is rendered + const indicator = screen.getByText('โ†—'); + expect(indicator).toBeInTheDocument(); + + // Check that the percentage is displayed + expect(screen.getByText('0.5%')).toBeInTheDocument(); + + // Check that the comparison period text is displayed + // Period text is no longer displayed in minimalist design + }); + + it('should render large positive change correctly', () => { + const props = { + ...defaultProps, + percentageChange: 2.0, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 week ago', + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('2%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + + it('should render small positive change correctly', () => { + const props = { + ...defaultProps, + percentageChange: 0.01, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 month ago', + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.01%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + }); + + describe('Time Comparison - Negative Changes', () => { + it('should render negative comparison indicator with red color and down arrow', () => { + const props = { + ...defaultProps, + percentageChange: -0.25, + comparisonIndicator: 'negative' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + // Check that the comparison indicator is rendered + const indicator = screen.getByText('โ†˜'); + expect(indicator).toBeInTheDocument(); + + // Check that the percentage is displayed + expect(screen.getByText('-0.25%')).toBeInTheDocument(); + + // Check that the comparison period text is displayed + // Period text is no longer displayed in minimalist design + }); + + it('should render large negative change correctly', () => { + const props = { + ...defaultProps, + percentageChange: -0.75, + comparisonIndicator: 'negative' as const, + comparisonPeriodText: '1 week ago', + }; + + render(); + + expect(screen.getByText('โ†˜')).toBeInTheDocument(); + expect(screen.getByText('-0.75%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + + it('should render small negative change correctly', () => { + const props = { + ...defaultProps, + percentageChange: -0.05, + comparisonIndicator: 'negative' as const, + comparisonPeriodText: '1 month ago', + }; + + render(); + + expect(screen.getByText('โ†˜')).toBeInTheDocument(); + expect(screen.getByText('-0.05%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + }); + + describe('Time Comparison - No Change', () => { + it('should render neutral comparison indicator with orange color and dash', () => { + const props = { + ...defaultProps, + percentageChange: 0, + comparisonIndicator: 'neutral' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + // Check that the comparison indicator is rendered + const indicator = screen.getByText('โˆ’'); + expect(indicator).toBeInTheDocument(); + + // Check that the percentage is displayed + expect(screen.getByText('0%')).toBeInTheDocument(); + + // Check that the comparison period text is displayed + // Period text is no longer displayed in minimalist design + }); + + it('should render very small change as neutral', () => { + const props = { + ...defaultProps, + percentageChange: 0.0001, + comparisonIndicator: 'neutral' as const, + comparisonPeriodText: '1 week ago', + }; + + render(); + + expect(screen.getByText('โˆ’')).toBeInTheDocument(); + expect(screen.getByText('0.0001%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + }); + + describe('Time Comparison - Edge Cases', () => { + it('should not render comparison indicator when percentageChange is undefined', () => { + const props = { + ...defaultProps, + percentageChange: undefined, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + expect(screen.queryByText('โ†—')).not.toBeInTheDocument(); + expect(screen.queryByText('vs 1 day ago')).not.toBeInTheDocument(); + }); + + it('should not render comparison indicator when comparisonIndicator is undefined', () => { + const props = { + ...defaultProps, + percentageChange: 0.5, + comparisonIndicator: undefined, + comparisonPeriodText: '1 day ago', + }; + + render(); + + expect(screen.queryByText('โ†—')).not.toBeInTheDocument(); + expect(screen.queryByText('vs 1 day ago')).not.toBeInTheDocument(); + }); + + it('should render comparison indicator without period text when comparisonPeriodText is undefined', () => { + const props = { + ...defaultProps, + percentageChange: 0.5, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: undefined, + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.5%')).toBeInTheDocument(); + expect(screen.queryByText('vs')).not.toBeInTheDocument(); + }); + + it('should render comparison indicator without period text when comparisonPeriodText is empty', () => { + const props = { + ...defaultProps, + percentageChange: 0.5, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '', + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.5%')).toBeInTheDocument(); + expect(screen.queryByText('vs')).not.toBeInTheDocument(); + }); + }); + + describe('Different Time Periods', () => { + it('should render "inherit" time comparison correctly', () => { + const props = { + ...defaultProps, + percentageChange: 0.33, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: 'inherit', + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.33%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + + it('should render "1 week ago" time comparison correctly', () => { + const props = { + ...defaultProps, + percentageChange: 0.2, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 week ago', + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.2%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + + it('should render "1 month ago" time comparison correctly', () => { + const props = { + ...defaultProps, + percentageChange: 0.15, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 month ago', + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.15%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + + it('should render custom time comparison correctly', () => { + const props = { + ...defaultProps, + percentageChange: 0.1, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: 'custom_range', + }; + + render(); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.1%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + }); + + describe('Styling and Layout', () => { + it('should position comparison indicator in top right corner', () => { + const props = { + ...defaultProps, + percentageChange: 0.5, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + const indicator = screen.getByText('โ†—').closest('div'); + expect(indicator).toHaveStyle({ + position: 'absolute', + top: '-35px', // Position above chart to appear in header area + right: '60px', // Position left of controls menu + }); + }); + + it('should apply correct colors for different indicators', () => { + // Test positive indicator + const { rerender } = render( + , + ); + + let indicator = screen.getByText('โ†—').closest('div'); + expect(indicator).toHaveStyle({ color: '#28a745' }); + + // Test negative indicator + rerender( + , + ); + + indicator = screen.getByText('โ†˜').closest('div'); + expect(indicator).toHaveStyle({ color: '#dc3545' }); + + // Test neutral indicator + rerender( + , + ); + + indicator = screen.getByText('โˆ’').closest('div'); + expect(indicator).toHaveStyle({ color: '#ffc107' }); + }); + + it('should handle NaN percentage values gracefully', () => { + const props = { + ...defaultProps, + percentageChange: NaN, + comparisonIndicator: 'neutral' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + // Should display 0% instead of NaN + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(screen.getByText('โˆ’')).toBeInTheDocument(); // Neutral arrow + + const indicator = screen.getByText('โˆ’').closest('div'); + expect(indicator).toHaveStyle({ color: '#ffc107' }); // Amber color + }); + + it('should handle undefined percentage values gracefully', () => { + const props = { + ...defaultProps, + percentageChange: undefined as any, + comparisonIndicator: undefined as any, // When percentageChange is undefined, comparisonIndicator should also be undefined + comparisonPeriodText: '1 day ago', + }; + + render(); + + // Should not render comparison indicator when data is undefined + expect(screen.queryByText('0%')).not.toBeInTheDocument(); + expect(screen.queryByText('โˆ’')).not.toBeInTheDocument(); + expect(screen.queryByText('โ†—')).not.toBeInTheDocument(); + expect(screen.queryByText('โ†˜')).not.toBeInTheDocument(); + }); + + it('should display proper tooltip text for different time periods', () => { + const inheritProps = { + ...defaultProps, + percentageChange: 0.1, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: 'inherit', + }; + + const { rerender } = render(); + + const indicator = screen.getByText('โ†—').closest('div'); + expect(indicator).toHaveAttribute('title', 'Compared to previous period'); + + // Test inherit mode with specific date range + rerender( + , + ); + + const inheritIndicator = screen.getByText('โ†—').closest('div'); + expect(inheritIndicator?.getAttribute('title')).toContain('Compared to'); + expect(inheritIndicator?.getAttribute('title')).toContain('2023'); // Previous year dates + expect(inheritIndicator?.getAttribute('title')).not.toContain( + 'vs current', + ); // Should not contain vs current + + // Test with specific time period + rerender( + , + ); + + const weekIndicator = screen.getByText('โ†—').closest('div'); + expect(weekIndicator).toHaveAttribute('title', 'Compared to 1 week ago'); + + // Test with custom range + rerender( + , + ); + + const customIndicator = screen.getByText('โ†—').closest('div'); + expect(customIndicator).toHaveAttribute( + 'title', + 'Compared to custom date range', + ); + }); + + it('should maintain proper spacing and typography', () => { + const props = { + ...defaultProps, + percentageChange: 0.5, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + const indicator = screen.getByText('โ†—').closest('div'); + expect(indicator).toHaveStyle({ + display: 'flex', + alignItems: 'center', + gap: '3px', // Smaller gap + fontSize: 'clamp(8px, 2.0vw, 14px)', // Responsive font size + fontWeight: '500', // Lighter weight + whiteSpace: 'nowrap', // Prevent text wrapping + }); + }); + }); + + describe('Integration with Existing Features', () => { + it('should work with conditional formatting', () => { + const props = { + ...defaultProps, + percentageChange: 0.5, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 day ago', + conditionalFormatting: [ + { + operator: '>', + targetValue: 500, + color: '#ff0000', + }, + ], + }; + + render(); + + // Should still render the comparison indicator + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.5%')).toBeInTheDocument(); + // Period text is no longer displayed in minimalist design + }); + + it('should work with custom number formatting', () => { + const customFormatNumber = (value: number) => + `$${value.toLocaleString()}`; + const props = { + ...defaultProps, + formatNumber: customFormatNumber, + percentageChange: 0.5, + comparisonIndicator: 'positive' as const, + comparisonPeriodText: '1 day ago', + }; + + render(); + + // Should render the main number with custom formatting + expect(screen.getByText('1000%')).toBeInTheDocument(); + + // Should still render the comparison indicator + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.5%')).toBeInTheDocument(); + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberWithTrendline.buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberWithTrendline.buildQuery.test.ts new file mode 100644 index 000000000000..6688ad85e7da --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberWithTrendline.buildQuery.test.ts @@ -0,0 +1,438 @@ +/** + * 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 { QueryFormData, SqlaFormData } from '@superset-ui/core'; +import buildQuery from '../../src/BigNumber/BigNumberWithTrendline/buildQuery'; + +describe('BigNumberWithTrendline buildQuery with Time Comparison', () => { + const baseFormData: SqlaFormData = { + viz_type: 'big_number_with_trendline', + datasource: 'test_datasource', + x_axis: 'ds', + time_grain_sqla: 'P1D', + metric: 'value', + } as SqlaFormData; + + describe('Basic Query Building', () => { + it('should build query with basic time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + }); + + describe('Time Comparison Types', () => { + it('should work with "inherit" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); // inherit resolves to "1 day ago" + }); + + it('should detect time period from time range string - Last 4 days', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + time_range: 'Last 4 days', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['4 days ago']); + }); + + it('should detect time period from time range string - Last 1 week', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + time_range: 'Last 1 week', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 week ago']); + }); + + it('should detect time period from time range string - Last 2 months', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + time_range: 'Last 2 months', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', [ + '2 months ago', + ]); + }); + + it('should calculate period from since/until dates - 4 days', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + since: '2023-08-23', + until: '2023-08-26', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['4 days ago']); + }); + + it('should calculate period from since/until dates - 1 week', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + since: '2023-08-20', + until: '2023-08-26', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 week ago']); + }); + + it('should handle special time range cases - today', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + time_range: 'Today', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 day ago']); + }); + + it('should handle special time range cases - this week', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + time_range: 'This week', + }; + const result = buildQuery(formData); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('time_offsets', ['1 week ago']); + }); + + it('should work with "1 day ago" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + + it('should work with "1 week ago" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 week ago' } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 week ago'); + }); + + it('should work with "1 month ago" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 month ago' } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 month ago'); + }); + + it('should work with "1 year ago" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 year ago' } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 year ago'); + }); + + it('should work with "custom" time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { + time_compare: 'custom', + time_compare_value: '7 days ago', + } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('7 days ago'); // custom uses time_compare_value + }); + }); + + describe('Trendline-Specific Properties', () => { + it('should include columns for x-axis when set', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('columns'); + expect(result.queries[0].columns).toContainEqual({ + columnType: 'BASE_AXIS', + expressionType: 'SQL', + label: 'ds', + sqlExpression: 'ds', + timeGrain: 'P1D', + }); + }); + + it('should set is_timeseries when x_axis is not set', () => { + const formData: SqlaFormData = { + ...baseFormData, + x_axis: undefined, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('is_timeseries', true); + }); + + it('should include post_processing for both queries', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('post_processing'); + expect(result.queries[0].post_processing).toBeInstanceOf(Array); + }); + + it('should maintain post_processing consistency across queries', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + const firstPostProcessing = result.queries[0].post_processing; + + expect(firstPostProcessing).toBeInstanceOf(Array); + expect(firstPostProcessing).toHaveLength(2); // pivot and flatten operations + }); + }); + + describe('Edge Cases', () => { + it('should work with undefined time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: undefined } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + // Should not have time_offsets when time_compare is undefined + expect(result.queries[0]).not.toHaveProperty('time_offsets'); + }); + + it('should work with null time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: null } as any, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + // Should not have time_offsets when time_compare is null + expect(result.queries[0]).not.toHaveProperty('time_offsets'); + }); + + it('should handle empty extra_form_data', () => { + const formData = { + ...baseFormData, + extra_form_data: {}, + }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + + it('should handle missing extra_form_data', () => { + const formData = { ...baseFormData }; + const result = buildQuery(formData); + + expect(result).toHaveProperty('queries'); + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + }); + + describe('Query Structure', () => { + it('should maintain metric consistency across queries', () => { + const formData: SqlaFormData = { + ...baseFormData, + metric: 'sales', + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('sales'); + }); + + it('should maintain datasource consistency across queries', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + // Note: buildQueryContext doesn't preserve datasource in query objects + // The datasource is handled at the form data level + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + + it('should preserve additional form data properties', () => { + const formData: SqlaFormData = { + ...baseFormData, + filters: ['filter1', 'filter2'], + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + // Note: buildQueryContext doesn't preserve filters in query objects + // The filters are handled at the form data level + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); + }); + + it('should maintain time grain consistency', () => { + const formData: SqlaFormData = { + ...baseFormData, + time_grain_sqla: 'PT1H', + extra_form_data: { time_compare: '1 day ago' } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + // Note: buildQueryContext doesn't preserve time_grain_sqla in query objects + // The time_grain_sqla is handled at the form data level + expect(result.queries[0]).toHaveProperty('metrics'); + expect(result.queries[0].metrics).toContain('value'); + }); + }); + + describe('Integration with Superset Core', () => { + it('should use getComparisonInfo for time comparison', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { time_compare: 'inherit' } as any, + }; + const result = buildQuery(formData); + + // The second query should have comparison-specific properties + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toBeDefined(); + + // Verify that the comparison query is properly structured + expect(result.queries[0]).toHaveProperty('columns'); + expect(result.queries[0]).toHaveProperty('post_processing'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); // inherit resolves to "1 day ago" + }); + + it('should handle complex time comparison scenarios', () => { + const formData: SqlaFormData = { + ...baseFormData, + extra_form_data: { + time_compare: 'inherit', + time_compare_value: 'custom_range', + } as any, + }; + const result = buildQuery(formData); + + expect(result.queries).toHaveLength(1); + expect(result.queries[0]).toBeDefined(); + + // Both should maintain trendline-specific properties + expect(result.queries[0]).toHaveProperty('post_processing'); + expect(result.queries[0]).toHaveProperty('time_offsets'); + expect(result.queries[0].time_offsets).toContain('1 day ago'); // inherit resolves to "1 day ago" + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberWithTrendline.transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberWithTrendline.transformProps.test.ts new file mode 100644 index 000000000000..3ed438add676 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/BigNumberWithTrendline.transformProps.test.ts @@ -0,0 +1,567 @@ +/** + * 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 { + DatasourceType, + supersetTheme, + TimeGranularity, + QueryFormData, + QueryData, +} from '@superset-ui/core'; +import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps'; +import { BigNumberVizProps } from '../../src/BigNumber/types'; + +const formData: QueryFormData = { + metric: 'value', + viz_type: 'big_number_with_trendline', + yAxisFormat: '.3s', + datasource: 'test_datasource', + headerFontSize: 0.3, + subheaderFontSize: 0.125, + subheader: 'Test subheader', + forceTimestampFormatting: false, + currencyFormat: undefined, + colorPicker: { + r: 0, + g: 122, + b: 135, + a: 1, + }, + showTrendLine: true, + timeGrainSqla: TimeGranularity.DAY, + granularitySqla: 'ds', +}; + +function generateProps( + data: any[], + comparisonData: any[] = [], + extraFormData = {}, + extraQueryData: any = {}, +): any { + const queriesData = [ + { + data, + colnames: ['value'], + coltypes: ['DOUBLE'], + ...extraQueryData, + }, + ]; + + // Add comparison data if provided - now as time-offset columns in the same query + if (comparisonData.length > 0) { + // Get the time comparison value from extraFormData + const timeCompare = + (extraFormData as any)?.extra_form_data?.time_compare || + (extraFormData as any)?.time_compare; + + if (timeCompare && timeCompare !== 'NoComparison') { + // Resolve the actual time offset string + let resolvedTimeOffset: string; + if (timeCompare === 'inherit') { + resolvedTimeOffset = '1 day ago'; + } else if (timeCompare === 'custom') { + resolvedTimeOffset = + (extraFormData as any)?.time_compare_value || 'custom_range'; + } else { + resolvedTimeOffset = timeCompare; + } + + // Get the metric name from the formData (default to 'value' if not specified) + const metricName = (extraFormData as any)?.metric || 'value'; + + // Create time-offset column name using the actual metric name + const timeOffsetColumn = `${metricName}__${resolvedTimeOffset}`; + + // Update the first query to include both current and comparison data + queriesData[0] = { + data: data.map((row, index) => ({ + ...row, + [timeOffsetColumn]: comparisonData[index]?.[metricName] ?? null, + })), + colnames: [metricName, timeOffsetColumn], + coltypes: ['DOUBLE', 'DOUBLE'], + ...extraQueryData, + }; + } + } + + return { + width: 200, + height: 200, + formData: { ...formData, ...extraFormData }, + queriesData, + datasource: { + type: DatasourceType.Table, + columns: [], + metrics: [], + verboseMap: {}, + columnFormats: {}, + currencyFormats: {}, + }, + theme: supersetTheme, + rawFormData: { ...formData, ...extraFormData }, + hooks: {}, + initialValues: {}, + }; +} + +describe('BigNumberWithTrendline transformProps with Time Comparison', () => { + describe('Basic Functionality', () => { + it('should transform basic props correctly without time comparison', () => { + const props = generateProps([{ value: 1234567.89 }]); + const result = transformProps(props); + + expect(result).toMatchObject({ + width: 200, + height: 200, + bigNumber: 1234567.89, + className: expect.any(String), + }); + }); + + it('should handle empty data gracefully', () => { + const props = generateProps([]); + const result = transformProps(props); + + expect(result).toMatchObject({ + width: 200, + height: 200, + bigNumber: null, + className: expect.any(String), + }); + }); + }); + + describe('Time Comparison - Positive Changes', () => { + it('should handle 50% increase correctly', () => { + const props = generateProps( + [{ value: 1500 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1500, + previousPeriodValue: 1000, + percentageChange: 0.5, // (1500 - 1000) / 1000 = 0.5 + comparisonIndicator: 'positive', + }); + }); + + it('should handle 100% increase correctly', () => { + const props = generateProps( + [{ value: 2000 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 2000, + previousPeriodValue: 1000, + percentageChange: 1.0, // (2000 - 1000) / 1000 = 1.0 + comparisonIndicator: 'positive', + }); + }); + + it('should handle large percentage increases', () => { + const props = generateProps( + [{ value: 10000 }], // current period + [{ value: 100 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 10000, + previousPeriodValue: 100, + percentageChange: 99, // (10000 - 100) / 100 = 99 + comparisonIndicator: 'positive', + }); + }); + }); + + describe('Time Comparison - Negative Changes', () => { + it('should handle 20% decrease correctly', () => { + const props = generateProps( + [{ value: 800 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 800, + previousPeriodValue: 1000, + percentageChange: -0.2, // (800 - 1000) / 1000 = -0.2 + comparisonIndicator: 'negative', + }); + }); + + it('should handle 50% decrease correctly', () => { + const props = generateProps( + [{ value: 500 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 500, + previousPeriodValue: 1000, + percentageChange: -0.5, // (500 - 1000) / 1000 = -0.5 + comparisonIndicator: 'negative', + }); + }); + + it('should handle 90% decrease correctly', () => { + const props = generateProps( + [{ value: 100 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 100, + previousPeriodValue: 1000, + percentageChange: -0.9, // (100 - 1000) / 1000 = -0.9 + comparisonIndicator: 'negative', + }); + }); + }); + + describe('Time Comparison - No Change', () => { + it('should handle no change correctly', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: 1000, + percentageChange: 0, // (1000 - 1000) / 1000 = 0 + comparisonIndicator: 'neutral', + }); + }); + + it('should handle very small changes as positive', () => { + const props = generateProps( + [{ value: 1000.001 }], // current period + [{ value: 1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000.001, + previousPeriodValue: 1000, + comparisonIndicator: 'positive', + }); + // Use toBeCloseTo for floating point comparison + expect(result.percentageChange).toBeCloseTo(0.000001, 6); + }); + }); + + describe('Time Comparison - Zero Value Handling', () => { + it('should handle zero current and zero previous as neutral', () => { + const props = generateProps( + [{ value: 0 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 0, + previousPeriodValue: 0, + percentageChange: 0, // Both zero = no change + comparisonIndicator: 'neutral', + }); + }); + + it('should handle zero previous with positive current as 100% increase', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: 0, + percentageChange: 1, // Treated as 100% increase + comparisonIndicator: 'positive', + }); + }); + + it('should handle zero previous with negative current as -100% decrease', () => { + const props = generateProps( + [{ value: -1000 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: -1000, + previousPeriodValue: 0, + percentageChange: -1, // Treated as -100% decrease + comparisonIndicator: 'negative', + }); + }); + + it('should handle zero current with positive previous as -100% decrease (complete loss)', () => { + const props = generateProps( + [{ value: 0 }], // current period + [{ value: 102942 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 0, + previousPeriodValue: 102942, + percentageChange: -1, // -100% decrease (complete loss) + comparisonIndicator: 'negative', + }); + }); + }); + + describe('Time Comparison - Edge Cases', () => { + it('should handle zero previous period value', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: 0 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: 0, + percentageChange: 1, // Treated as 100% increase (0 to 1000) + comparisonIndicator: 'positive', + }); + }); + + it('should handle null previous period value', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [{ value: null }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + + it('should handle empty comparison data', () => { + const props = generateProps( + [{ value: 1000 }], // current period + [], // empty comparison data + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + + it('should handle negative values correctly', () => { + const props = generateProps( + [{ value: -500 }], // current period + [{ value: -1000 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: -500, + previousPeriodValue: -1000, + percentageChange: 0.5, // (-500 - (-1000)) / 1000 = 0.5 + comparisonIndicator: 'positive', + }); + }); + }); + + describe('Time Comparison - Different Time Periods', () => { + it('should work with "inherit" time comparison', () => { + const props = generateProps( + [{ value: 2000 }], // current period + [{ value: 1500 }], // previous period + { extra_form_data: { time_compare: 'inherit' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 2000, + previousPeriodValue: 1500, + percentageChange: 0.3333333333333333, // (2000 - 1500) / 1500 + comparisonIndicator: 'positive', + }); + }); + + it('should work with "1 week ago" time comparison', () => { + const props = generateProps( + [{ value: 3000 }], // current period + [{ value: 2500 }], // previous period + { extra_form_data: { time_compare: '1 week ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 3000, + previousPeriodValue: 2500, + percentageChange: 0.2, // (3000 - 2500) / 2500 = 0.2 + comparisonIndicator: 'positive', + }); + }); + + it('should work with "1 month ago" time comparison', () => { + const props = generateProps( + [{ value: 4000 }], // current period + [{ value: 3500 }], // previous period + { extra_form_data: { time_compare: '1 month ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 4000, + previousPeriodValue: 3500, + percentageChange: 0.14285714285714285, // (4000 - 3500) / 3500 + comparisonIndicator: 'positive', + }); + }); + }); + + describe('No Time Comparison', () => { + it('should handle no time comparison correctly', () => { + const props = generateProps([{ value: 1000 }]); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + + it('should handle undefined time comparison', () => { + const props = generateProps([{ value: 1000 }], [{ value: 800 }], { + extra_form_data: { time_compare: undefined }, + }); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + }); + + describe('Trendline Integration', () => { + it('should work with trendline and time comparison', () => { + const props = generateProps( + [{ value: 1500 }], // current period + [{ value: 1000 }], // previous period + { + showTrendLine: true, + extra_form_data: { time_compare: '1 day ago' }, + }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1500, + previousPeriodValue: 1000, + percentageChange: 0.5, + comparisonIndicator: 'positive', + showTrendLine: true, + }); + }); + + it('should handle trendline without time comparison', () => { + const props = generateProps([{ value: 1000 }], [], { + showTrendLine: true, + }); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1000, + showTrendLine: true, + previousPeriodValue: null, + percentageChange: undefined, + comparisonIndicator: undefined, + }); + }); + }); + + describe('Data Format Handling', () => { + it('should handle different metric names', () => { + const customFormData = { ...formData, metric: 'sales' }; + const props = generateProps( + [{ sales: 1500 }], // current period + [{ sales: 1000 }], // previous period + { ...customFormData, extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + expect(result).toMatchObject({ + bigNumber: 1500, + previousPeriodValue: 1000, + percentageChange: 0.5, + comparisonIndicator: 'positive', + }); + }); + + it('should handle multiple data rows', () => { + const props = generateProps( + [{ value: 1500 }, { value: 1600 }], // current period + [{ value: 1000 }, { value: 1100 }], // previous period + { extra_form_data: { time_compare: '1 day ago' } }, + ); + const result = transformProps(props); + + // Should use the first row for comparison + expect(result).toMatchObject({ + bigNumber: 1500, + previousPeriodValue: 1000, + percentageChange: 0.5, + comparisonIndicator: 'positive', + }); + }); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts deleted file mode 100644 index 8b0bf3552585..000000000000 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * 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 { - DatasourceType, - supersetTheme, - TimeGranularity, - VizType, -} from '@superset-ui/core'; -import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps'; -import { - BigNumberDatum, - BigNumberWithTrendlineChartProps, - BigNumberWithTrendlineFormData, -} from '../../src/BigNumber/types'; - -const formData = { - metric: 'value', - colorPicker: { - r: 0, - g: 122, - b: 135, - a: 1, - }, - compareLag: 1, - timeGrainSqla: TimeGranularity.QUARTER, - granularitySqla: 'ds', - compareSuffix: 'over last quarter', - viz_type: VizType.BigNumber, - yAxisFormat: '.3s', - datasource: 'test_datasource', -}; - -const rawFormData: BigNumberWithTrendlineFormData = { - colorPicker: { b: 0, g: 0, r: 0 }, - datasource: '1__table', - metric: 'value', - color_picker: { - r: 0, - g: 122, - b: 135, - a: 1, - }, - compare_lag: 1, - time_grain_sqla: TimeGranularity.QUARTER, - granularity_sqla: 'ds', - compare_suffix: 'over last quarter', - viz_type: VizType.BigNumber, - y_axis_format: '.3s', -}; - -function generateProps( - data: BigNumberDatum[], - extraFormData = {}, - extraQueryData: any = {}, -): BigNumberWithTrendlineChartProps { - return { - width: 200, - height: 500, - annotationData: {}, - datasource: { - id: 0, - name: '', - type: DatasourceType.Table, - columns: [], - metrics: [], - columnFormats: {}, - verboseMap: {}, - }, - rawDatasource: {}, - rawFormData, - hooks: {}, - initialValues: {}, - formData: { - ...formData, - ...extraFormData, - }, - queriesData: [ - { - data, - ...extraQueryData, - }, - ], - ownState: {}, - filterState: {}, - behaviors: [], - theme: supersetTheme, - }; -} - -describe('BigNumberWithTrendline', () => { - const props = generateProps( - [ - { - __timestamp: 0, - value: 1.2345, - }, - { - __timestamp: 100, - value: null, - }, - ], - { showTrendLine: true }, - ); - - describe('transformProps()', () => { - it('should fallback and format time', () => { - const transformed = transformProps(props); - // the first item is the last item sorted by __timestamp - const lastDatum = transformed.trendLineData?.pop(); - - // should use last available value - expect(lastDatum?.[0]).toStrictEqual(100); - expect(lastDatum?.[1]).toBeNull(); - - // should note this is a fallback - expect(transformed.bigNumber).toStrictEqual(1.2345); - expect(transformed.bigNumberFallback).not.toBeNull(); - - // should successfully formatTime by granularity - // @ts-ignore - expect(transformed.formatTime(new Date('2020-01-01'))).toStrictEqual( - '2020-01-01 00:00:00', - ); - }); - - it('should respect datasource d3 format', () => { - const propsWithDatasource = { - ...props, - datasource: { - ...props.datasource, - metrics: [ - { - label: 'value', - metric_name: 'value', - d3format: '.2f', - }, - ], - }, - }; - const transformed = transformProps(propsWithDatasource); - // @ts-ignore - expect(transformed.headerFormatter(transformed.bigNumber)).toStrictEqual( - '1.23', - ); - }); - - it('should format with datasource currency', () => { - const propsWithDatasource = { - ...props, - datasource: { - ...props.datasource, - currencyFormats: { - value: { symbol: 'USD', symbolPosition: 'prefix' }, - }, - metrics: [ - { - label: 'value', - metric_name: 'value', - d3format: '.2f', - currency: { symbol: 'USD', symbolPosition: 'prefix' }, - }, - ], - }, - }; - const transformed = transformProps(propsWithDatasource); - // @ts-ignore - expect(transformed.headerFormatter(transformed.bigNumber)).toStrictEqual( - '$ 1.23', - ); - }); - }); -}); diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index 6b62498533f3..4d4196f7e740 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -989,9 +989,7 @@ export default function TableChart( `} > {t('Summary')} - +
diff --git a/superset-frontend/src/GlobalStyles.tsx b/superset-frontend/src/GlobalStyles.tsx index 09a2792dde11..289ac2fdaa1f 100644 --- a/superset-frontend/src/GlobalStyles.tsx +++ b/superset-frontend/src/GlobalStyles.tsx @@ -34,6 +34,7 @@ export const GlobalStyles = () => ( th { font-weight: ${theme.typography.weights.bold}; } + // CSS hack to resolve the issue caused by the invisible echart tooltip on // https://github.com/apache/superset/issues/30058 .echarts-tooltip[style*='visibility: hidden'] { @@ -112,6 +113,81 @@ export const GlobalStyles = () => ( .ant-dropdown-menu-item { line-height: 1.5em !important; } + + /* Enhanced dropdown menu styling for better appearance */ + .ant-dropdown, + .antd5-dropdown { + z-index: ${theme.zIndex.max} !important; + + .ant-dropdown-menu, + .antd5-menu-vertical { + min-width: 160px; + max-width: 220px; + border-radius: ${theme.borderRadius}px; + background-color: ${theme.colors.grayscale.light5}; + border: 1px solid ${theme.colors.grayscale.light2}; + overflow: hidden; + padding: ${theme.gridUnit}px 0; + + .antd5-menu-item-group-title { + color: ${theme.colors.grayscale.base}; + font-weight: ${theme.typography.weights.bold}; + font-size: ${theme.typography.sizes.s}px; + padding: ${theme.gridUnit * 1.5}px ${theme.gridUnit * 3}px + ${theme.gridUnit}px; + line-height: 1.4; + margin-bottom: ${theme.gridUnit / 2}px; + } + + .antd5-menu-item { + padding: ${theme.gridUnit * 1.25}px ${theme.gridUnit * 3}px; + margin: 0 0 ${theme.gridUnit / 2}px 0; + line-height: 1.4; + + &:hover { + background-color: ${theme.colors.primary.light5}; + } + + a { + color: ${theme.colors.grayscale.dark1}; + text-decoration: none; + + &:hover { + color: ${theme.colors.primary.base}; + } + } + } + + .antd5-menu-item-divider { + margin: ${theme.gridUnit / 2}px 0; + background-color: ${theme.colors.grayscale.light2}; + height: 1px; + } + } + } + + /* Fix Switch component styling */ + .antd5-switch { + &.antd5-switch-checked { + background-color: ${theme.colors.primary.base}; + } + + &.antd5-switch-checked:hover:not(.antd5-switch-disabled) { + background-color: ${theme.colors.primary.dark1}; + } + + .antd5-switch-handle { + background-color: ${theme.colors.grayscale.light5}; + } + + &:not(.antd5-switch-checked) { + background-color: ${theme.colors.grayscale.light2}; + } + + &:not(.antd5-switch-checked):hover:not(.antd5-switch-disabled) { + background-color: ${theme.colors.grayscale.light1}; + } + } `} /> ); diff --git a/superset-frontend/src/components/Alert/index.tsx b/superset-frontend/src/components/Alert/index.tsx index b8fb872b6a24..88efb8e42b1a 100644 --- a/superset-frontend/src/components/Alert/index.tsx +++ b/superset-frontend/src/components/Alert/index.tsx @@ -19,11 +19,41 @@ import { PropsWithChildren } from 'react'; import { Alert as AntdAlert } from 'antd-v5'; import { AlertProps as AntdAlertProps } from 'antd-v5/lib/alert'; +import { styled } from '@superset-ui/core'; export type AlertProps = PropsWithChildren< Omit & { roomBelow?: boolean } >; +const StyledAlert = styled(AntdAlert)<{ type?: string }>` + ${({ type }) => + type === 'error' && + ` + background: #FFF2EC !important; + border: 1px solid #FFA000 !important; + color: #7F3F21 !important; + font-size: 14px !important; + + .antd5-alert-message { + color: #7F3F21 !important; + font-size: 14px !important; + } + + .antd5-alert-description { + color: #7F3F21 !important; + font-size: 14px !important; + } + + .antd5-alert-icon { + color: #FFA000 !important; + } + + .antd5-alert-close-icon { + color: #7F3F21 !important; + } + `} +`; + export default function Alert(props: AlertProps) { const { type = 'info', @@ -35,7 +65,7 @@ export default function Alert(props: AlertProps) { } = props; return ( - ( ({ - // 'border-radius': `${theme.gridUnit}px`, + 'border-radius': `${theme.borderRadius}px !important`, border: `1px solid ${theme.colors.grayscale.light2}`, '.antd5-card-body': { padding: padded ? theme.gridUnit * 4 : theme.gridUnit, diff --git a/superset-frontend/src/components/Chart/AISummary.integration.test.tsx b/superset-frontend/src/components/Chart/AISummary.integration.test.tsx index 4fceb4f18e21..3cd9596331e8 100644 --- a/superset-frontend/src/components/Chart/AISummary.integration.test.tsx +++ b/superset-frontend/src/components/Chart/AISummary.integration.test.tsx @@ -19,9 +19,8 @@ */ import { render, screen, waitFor } from 'spec/helpers/testing-library'; -import { FeatureFlag } from '@superset-ui/core'; +import { FeatureFlag, supersetTheme } from '@superset-ui/core'; import { ThemeProvider } from '@emotion/react'; -import { supersetTheme } from '@superset-ui/core'; import ChartRenderer from './ChartRenderer'; // Mock fetch globally diff --git a/superset-frontend/src/components/Chart/AISummaryBox.test.tsx b/superset-frontend/src/components/Chart/AISummaryBox.test.tsx index 409a7757ffea..e613273fafa0 100644 --- a/superset-frontend/src/components/Chart/AISummaryBox.test.tsx +++ b/superset-frontend/src/components/Chart/AISummaryBox.test.tsx @@ -17,7 +17,12 @@ * under the License. */ -import { render, screen, waitFor, fireEvent } from 'spec/helpers/testing-library'; +import { + render, + screen, + waitFor, + fireEvent, +} from 'spec/helpers/testing-library'; import { ThemeProvider } from '@emotion/react'; import { supersetTheme } from '@superset-ui/core'; import AISummaryBox from './AISummaryBox'; @@ -32,9 +37,10 @@ jest.mock('../../utils/aiSummary', () => ({ const mockGenerateSummary = aiSummary.generateSummary as jest.MockedFunction< typeof aiSummary.generateSummary >; -const mockExtractRawDataSample = aiSummary.extractRawDataSample as jest.MockedFunction< - typeof aiSummary.extractRawDataSample ->; +const mockExtractRawDataSample = + aiSummary.extractRawDataSample as jest.MockedFunction< + typeof aiSummary.extractRawDataSample + >; const defaultProps = { chartDomId: 'chart-id-123', @@ -54,13 +60,12 @@ const defaultProps = { filters: { region: 'North America' }, }; -const renderComponent = (props = {}) => { - return render( +const renderComponent = (props = {}) => + render( , ); -}; describe('AISummaryBox', () => { beforeEach(() => { @@ -81,7 +86,8 @@ describe('AISummaryBox', () => { }); it('should call generateSummary with title and description', async () => { - const mockSummary = 'This chart shows strong sales growth over the first quarter.'; + const mockSummary = + 'This chart shows strong sales growth over the first quarter.'; mockGenerateSummary.mockResolvedValue(mockSummary); renderComponent(); @@ -110,7 +116,8 @@ describe('AISummaryBox', () => { }); it('should display AI summary when successful', async () => { - const mockSummary = 'This chart shows strong sales growth over the first quarter.'; + const mockSummary = + 'This chart shows strong sales growth over the first quarter.'; mockGenerateSummary.mockResolvedValue(mockSummary); renderComponent(); @@ -231,7 +238,8 @@ describe('AISummaryBox', () => { }); it('should handle long summaries with expand/collapse', async () => { - const longSummary = 'This is a very long summary that should be truncated. '.repeat(20); + const longSummary = + 'This is a very long summary that should be truncated. '.repeat(20); mockGenerateSummary.mockResolvedValue(longSummary); renderComponent(); diff --git a/superset-frontend/src/components/Chart/AISummaryBox.tsx b/superset-frontend/src/components/Chart/AISummaryBox.tsx index 9b5202299972..d322cf588d01 100644 --- a/superset-frontend/src/components/Chart/AISummaryBox.tsx +++ b/superset-frontend/src/components/Chart/AISummaryBox.tsx @@ -53,7 +53,6 @@ const Border = styled('div')` box-shadow: none; `; -// background: ${({ theme }) => rgba(theme.colors.grayscale.light5, 0.25)}; const Container = styled('div')<{ hasActionButton: boolean }>` border-radius: 9px; color: inherit; @@ -71,6 +70,7 @@ const Container = styled('div')<{ hasActionButton: boolean }>` column-gap: 8px; user-select: none; `; + const SkeletonLine = styled('div')` height: 12px; width: 100%; @@ -430,15 +430,30 @@ export default function AISummaryBox({ if (loading) { return ( - - -
+ + +
-
- - -
+
+ + +
@@ -448,14 +463,25 @@ export default function AISummaryBox({ if (!shouldShow) return null; return ( - - -
+ + +
- + {expanded ? ( - + {fullText}{' '} view less ) : ( <> - + {collapsedText} {needsMore ? ( <> @@ -488,13 +515,17 @@ export default function AISummaryBox({ }} aria-label="view more" title="view more" + className="ai-summary-box__toggle-link ai-summary-box__toggle-link--more" > view more ) : null} - + )} diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx index 588eced196c3..7af4e2f093b0 100644 --- a/superset-frontend/src/components/Chart/Chart.tsx +++ b/superset-frontend/src/components/Chart/Chart.tsx @@ -161,7 +161,7 @@ const LoadingDiv = styled.div` position: absolute; left: 50%; top: 50%; - width: 80%; + width: 100%; transform: translate(-50%, -50%); `; diff --git a/superset-frontend/src/components/Chart/ChartRenderer.description.test.jsx b/superset-frontend/src/components/Chart/ChartRenderer.description.test.jsx index 00cc9271f751..79c058266984 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.description.test.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.description.test.jsx @@ -18,9 +18,8 @@ */ import { render, screen, waitFor } from 'spec/helpers/testing-library'; -import { FeatureFlag } from '@superset-ui/core'; +import { FeatureFlag, supersetTheme } from '@superset-ui/core'; import { ThemeProvider } from '@emotion/react'; -import { supersetTheme } from '@superset-ui/core'; import ChartRenderer from './ChartRenderer'; // Mock dependencies @@ -37,17 +36,19 @@ jest.mock('./ChartContextMenu/ChartContextMenu', () => () => ( )); // Mock AISummaryBox to verify props -jest.mock('./AISummaryBox', () => { - return function MockAISummaryBox(props) { - return ( -
-
{props.title}
-
{props.description}
-
{props.vizType}
-
- ); - }; -}); +jest.mock( + './AISummaryBox', + () => + function MockAISummaryBox(props) { + return ( +
+
{props.title}
+
{props.description}
+
{props.vizType}
+
+ ); + }, +); const mockIsFeatureEnabled = require('@superset-ui/core').isFeatureEnabled; @@ -74,7 +75,7 @@ const baseProps = { const renderChartRenderer = (props = {}) => { // Enable AI summary feature flag - mockIsFeatureEnabled.mockImplementation((flag) => { + mockIsFeatureEnabled.mockImplementation(flag => { if (flag === FeatureFlag.AiSummary) return true; return false; }); @@ -102,8 +103,12 @@ describe('ChartRenderer - Description Integration', () => { expect(screen.getByTestId('mock-ai-summary-box')).toBeInTheDocument(); }); - expect(screen.getByTestId('ai-description')).toHaveTextContent('Test Chart Description'); - expect(screen.getByTestId('ai-title')).toHaveTextContent('Test Chart Title'); + expect(screen.getByTestId('ai-description')).toHaveTextContent( + 'Test Chart Description', + ); + expect(screen.getByTestId('ai-title')).toHaveTextContent( + 'Test Chart Title', + ); expect(screen.getByTestId('ai-viztype')).toHaveTextContent('line'); }); @@ -144,7 +149,9 @@ describe('ChartRenderer - Description Integration', () => { renderChartRenderer({ title: 'Custom Chart Title' }); await waitFor(() => { - expect(screen.getByTestId('ai-title')).toHaveTextContent('Custom Chart Title'); + expect(screen.getByTestId('ai-title')).toHaveTextContent( + 'Custom Chart Title', + ); }); }); @@ -185,7 +192,9 @@ describe('ChartRenderer - Description Integration', () => { renderChartRenderer({ description: longDescription }); await waitFor(() => { - expect(screen.getByTestId('ai-description')).toHaveTextContent(longDescription); + expect(screen.getByTestId('ai-description')).toHaveTextContent( + longDescription, + ); }); }); @@ -195,7 +204,9 @@ describe('ChartRenderer - Description Integration', () => { renderChartRenderer({ description: specialDescription }); await waitFor(() => { - expect(screen.getByTestId('ai-description')).toHaveTextContent(specialDescription); + expect(screen.getByTestId('ai-description')).toHaveTextContent( + specialDescription, + ); }); }); }); diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx index ca5e425bad67..b2bbf8feef5b 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.jsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx @@ -396,33 +396,35 @@ class ChartRenderer extends Component { {...drillToDetailProps} /> {showAISummary && ( -
- { - const prev = this.state.aiBoxHeight || 0; - const threshold = 4; // ignore tiny changes to avoid jitter - if (Math.abs((h || 0) - prev) > threshold) { - if (this.aiHeightUpdateId) - window.cancelAnimationFrame(this.aiHeightUpdateId); - this.aiHeightUpdateId = window.requestAnimationFrame(() => { - if (this.state.aiBoxHeight !== h) - this.setState({ aiBoxHeight: h }); - }); - } +
+ > + { + const prev = this.state.aiBoxHeight || 0; + const threshold = 4; // ignore tiny changes to avoid jitter + if (Math.abs((h || 0) - prev) > threshold) { + if (this.aiHeightUpdateId) { + window.cancelAnimationFrame(this.aiHeightUpdateId); + } + this.aiHeightUpdateId = window.requestAnimationFrame(() => { + if (this.state.aiBoxHeight !== h) { + this.setState({ aiBoxHeight: h }); + } + }); + } + }} + />
)}
diff --git a/superset-frontend/src/components/Chart/timezoneChartActions.js b/superset-frontend/src/components/Chart/timezoneChartActions.js index 196e7cec3c84..34306a944cec 100644 --- a/superset-frontend/src/components/Chart/timezoneChartActions.js +++ b/superset-frontend/src/components/Chart/timezoneChartActions.js @@ -69,7 +69,6 @@ export function convertFormDataForAPI(formData) { ], }); - return result; } catch (error) { console.error('โŒ [TIMEZONE CONVERSION ERROR]:', error); @@ -122,7 +121,9 @@ export function convertAnnotationFormDataForAPI(annotation, formData) { // Convert annotation overrides const convertedAnnotation = { ...annotation }; if (annotation.overrides) { - convertedAnnotation.overrides = convertRequestDatesToUTC(annotation.overrides); + convertedAnnotation.overrides = convertRequestDatesToUTC( + annotation.overrides, + ); } // Convert form data @@ -133,7 +134,10 @@ export function convertAnnotationFormDataForAPI(annotation, formData) { formData: convertedFormData, }; } catch (error) { - console.warn('[Timezone] Failed to convert annotation form data dates to UTC:', error); + console.warn( + '[Timezone] Failed to convert annotation form data dates to UTC:', + error, + ); return { annotation, formData }; } } diff --git a/superset-frontend/src/components/ListView/CardCollection.tsx b/superset-frontend/src/components/ListView/CardCollection.tsx index 550cb84c3903..7328b87f765e 100644 --- a/superset-frontend/src/components/ListView/CardCollection.tsx +++ b/superset-frontend/src/components/ListView/CardCollection.tsx @@ -34,7 +34,9 @@ const CardContainer = styled.div<{ showThumbnails?: boolean }>` ${({ theme, showThumbnails }) => ` display: grid; grid-gap: ${theme.gridUnit * 12}px ${theme.gridUnit * 4}px; - grid-template-columns: repeat(auto-fit, 300px); + grid-template-columns: repeat(auto-fit, 250px); // NOTE: changed from 300px to 250px + justify-content: center; + border-radius: 4px !important; margin-top: ${theme.gridUnit * -6}px; padding: ${ showThumbnails diff --git a/superset-frontend/src/components/ListViewCard/index.tsx b/superset-frontend/src/components/ListViewCard/index.tsx index b785961dbbd6..bb78b99c47fc 100644 --- a/superset-frontend/src/components/ListViewCard/index.tsx +++ b/superset-frontend/src/components/ListViewCard/index.tsx @@ -36,6 +36,8 @@ const listViewCardTheme = { components: { Card: { colorBgContainer: supersetTheme.colors.grayscale.light5, + borderRadiusLG: supersetTheme.borderRadius, + borderRadius: supersetTheme.borderRadius, }, }, }; diff --git a/superset-frontend/src/components/Menu/index.tsx b/superset-frontend/src/components/Menu/index.tsx index ff2c1c0b6ec2..00b8c889beba 100644 --- a/superset-frontend/src/components/Menu/index.tsx +++ b/superset-frontend/src/components/Menu/index.tsx @@ -82,12 +82,62 @@ const StyledMenu = styled(AntdMenu)` } &.antd5-menu-vertical, &.ant-dropdown-menu { + min-width: 160px; + max-width: 220px; + border-radius: ${theme.borderRadius}px; box-shadow: 0 3px 6px -4px ${addAlpha(theme.colors.grayscale.dark2, 0.12)}, - 0 6px 16px 0 - ${addAlpha(theme.colors.grayscale.dark2, 0.08)}, - 0 9px 28px 8px - ${addAlpha(theme.colors.grayscale.dark2, 0.05)}; + 0 6px 16px 0 ${addAlpha(theme.colors.grayscale.dark2, 0.08)}, + 0 9px 28px 8px ${addAlpha(theme.colors.grayscale.dark2, 0.05)}; + border: 1px solid ${theme.colors.grayscale.light2}; + background-color: ${theme.colors.grayscale.light5}; + overflow: hidden; + padding: ${theme.gridUnit}px 0; + + /* Fix for submenu arrow positioning */ + .antd5-menu-submenu-arrow { + position: absolute; + right: ${theme.gridUnit * 2}px; + top: 50%; + transform: translateY(-50%); + } + + /* Improve menu item group styling */ + .antd5-menu-item-group-title { + color: ${theme.colors.grayscale.base}; + font-weight: ${theme.typography.weights.bold}; + font-size: ${theme.typography.sizes.s}px; + padding: ${theme.gridUnit * 1.5}px ${theme.gridUnit * 3}px ${theme.gridUnit}px; + line-height: 1.4; + margin-bottom: ${theme.gridUnit / 2}px; + } + + /* Enhanced menu item styling */ + .antd5-menu-item { + padding: ${theme.gridUnit * 1.25}px ${theme.gridUnit * 3}px; + margin: 0 0 ${theme.gridUnit / 2}px 0; + line-height: 1.4; + + &:hover { + background-color: ${theme.colors.primary.light5}; + } + + a { + color: ${theme.colors.grayscale.dark1}; + text-decoration: none; + + &:hover { + color: ${theme.colors.primary.base}; + } + } + } + + /* Fix divider styling */ + .antd5-menu-item-divider { + margin: ${theme.gridUnit / 2}px 0; + background-color: ${theme.colors.grayscale.light2}; + height: 1px; + } } `} `; @@ -105,6 +155,7 @@ const StyledNav = styled(AntdMenu)` border-bottom: 2px solid transparent; padding: ${({ theme }) => theme.gridUnit * 2}px ${({ theme }) => theme.gridUnit * 4}px; + font-size: 14px; &:hover { background-color: ${({ theme }) => theme.colors.primary.light5}; border-bottom: 2px solid transparent; @@ -113,6 +164,9 @@ const StyledNav = styled(AntdMenu)` width: 100%; } } + a { + font-size: 14px; + } } &.antd5-menu-horizontal > .antd5-menu-item-selected { box-sizing: border-box; @@ -133,6 +187,11 @@ const StyledSubMenu = styled(AntdMenu.SubMenu)` .antd5-menu-submenu-title { display: flex; flex-direction: row-reverse; + align-items: center; + padding: ${({ theme }) => theme.gridUnit * 2}px + ${({ theme }) => theme.gridUnit * 4}px; + font-size: 14px; + &:after { content: ''; position: absolute; @@ -144,12 +203,32 @@ const StyledSubMenu = styled(AntdMenu.SubMenu)` transform: translateX(-50%); transition: all ${({ theme }) => theme.transitionTiming}s; } + + /* Fix icon alignment and size */ + .anticon { + margin-left: ${({ theme }) => theme.gridUnit}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; + } } .ant-dropdown-menu-submenu-arrow:before, .ant-dropdown-menu-submenu-arrow:after { content: none !important; } + + /* Ensure proper dropdown positioning */ + &.antd5-menu-submenu-horizontal { + .antd5-menu-submenu-popup { + top: 100% !important; + border-radius: ${({ theme }) => theme.borderRadius}px; + box-shadow: + 0 3px 6px -4px ${({ theme }) => addAlpha(theme.colors.grayscale.dark2, 0.12)}, + 0 6px 16px 0 + ${({ theme }) => addAlpha(theme.colors.grayscale.dark2, 0.08)}, + 0 9px 28px 8px + ${({ theme }) => addAlpha(theme.colors.grayscale.dark2, 0.05)}; + } + } `; export type MenuMode = AntdMenuProps['mode']; diff --git a/superset-frontend/src/components/TimezoneContext/TimezoneContext.test.tsx b/superset-frontend/src/components/TimezoneContext/TimezoneContext.test.tsx index d91522a977f1..6190ac0cb407 100644 --- a/superset-frontend/src/components/TimezoneContext/TimezoneContext.test.tsx +++ b/superset-frontend/src/components/TimezoneContext/TimezoneContext.test.tsx @@ -43,30 +43,32 @@ describe('TimezoneContext', () => { render( - + , ); const timezoneElement = screen.getByTestId('timezone'); - expect(timezoneElement).toBeTruthy(); - expect(timezoneElement.textContent).toBe('UTC'); + expect(timezoneElement).toBeInTheDocument(); + expect(timezoneElement).toHaveTextContent('UTC'); }); it('should format dates correctly', () => { render( - + , ); const formattedDate = screen.getByTestId('formatted-date'); const formattedDateTime = screen.getByTestId('formatted-datetime'); - expect(formattedDate).toBeTruthy(); - expect(formattedDateTime).toBeTruthy(); + expect(formattedDate).toBeInTheDocument(); + expect(formattedDateTime).toBeInTheDocument(); // Check that the formatted values contain expected patterns - expect(formattedDate.textContent).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(formattedDateTime.textContent).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + expect(formattedDate).toHaveTextContent(/^\d{4}-\d{2}-\d{2}$/); + expect(formattedDateTime).toHaveTextContent( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, + ); }); it('should throw error when useTimezone is used outside provider', () => { @@ -78,4 +80,4 @@ describe('TimezoneContext', () => { spy.mockRestore(); }); -}); \ No newline at end of file +}); diff --git a/superset-frontend/src/components/TimezoneContext/index.tsx b/superset-frontend/src/components/TimezoneContext/index.tsx index c82d6d5f88e4..32c79d4ae400 100644 --- a/superset-frontend/src/components/TimezoneContext/index.tsx +++ b/superset-frontend/src/components/TimezoneContext/index.tsx @@ -17,10 +17,19 @@ * under the License. */ -import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { + createContext, + useContext, + useEffect, + useState, + ReactNode, +} from 'react'; import moment from 'moment-timezone'; import { URL_PARAMS } from 'src/constants'; -import { getCurrentTimezone as getCurrentTimezoneUtil, isValidTimezone } from 'src/utils/dateUtils'; +import { + getCurrentTimezone as getCurrentTimezoneUtil, + isValidTimezone, +} from 'src/utils/dateUtils'; interface TimezoneContextType { timezone: string; @@ -31,7 +40,9 @@ interface TimezoneContextType { convertFromUTC: (utcDate: moment.MomentInput) => moment.Moment; } -const TimezoneContext = createContext(undefined); +const TimezoneContext = createContext( + undefined, +); const DEFAULT_TIMEZONE = 'Asia/Kolkata'; const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; @@ -50,9 +61,13 @@ export function TimezoneProvider({ children }: TimezoneProviderProps) { // Function to update timezone const setTimezone = (newTimezone: string) => { - const targetTz = isValidTimezone(newTimezone) ? newTimezone : DEFAULT_TIMEZONE; + const targetTz = isValidTimezone(newTimezone) + ? newTimezone + : DEFAULT_TIMEZONE; if (!isValidTimezone(newTimezone)) { - console.warn(`Invalid timezone: ${newTimezone}. Falling back to default: ${DEFAULT_TIMEZONE}`); + console.warn( + `Invalid timezone: ${newTimezone}. Falling back to default: ${DEFAULT_TIMEZONE}`, + ); } // Sync URL param so UI always reflects URL or default try { @@ -67,27 +82,25 @@ export function TimezoneProvider({ children }: TimezoneProviderProps) { }; // Function to format date in the current timezone - const formatDate = (date: moment.MomentInput, format = DEFAULT_DATE_FORMAT): string => { - return moment.tz(date, timezone).format(format); - }; + const formatDate = ( + date: moment.MomentInput, + format = DEFAULT_DATE_FORMAT, + ): string => moment.tz(date, timezone).format(format); // Function to format datetime in the current timezone - const formatDateTime = (date: moment.MomentInput, format = DEFAULT_DATETIME_FORMAT): string => { - return moment.tz(date, timezone).format(format); - }; + const formatDateTime = ( + date: moment.MomentInput, + format = DEFAULT_DATETIME_FORMAT, + ): string => moment.tz(date, timezone).format(format); // Convert a date from current timezone to UTC for API calls - const convertToUTC = (date: moment.MomentInput): moment.Moment => { + const convertToUTC = (date: moment.MomentInput): moment.Moment => // First parse the date in the current timezone, then convert to UTC - return moment.tz(date, timezone).utc(); - }; - + moment.tz(date, timezone).utc(); // Convert a UTC date to the current timezone for display - const convertFromUTC = (utcDate: moment.MomentInput): moment.Moment => { + const convertFromUTC = (utcDate: moment.MomentInput): moment.Moment => // Parse as UTC, then convert to current timezone - return moment.utc(utcDate).tz(timezone); - }; - + moment.utc(utcDate).tz(timezone); // Watch for URL parameter changes to always reflect URL or default useEffect(() => { const handlePopState = () => { @@ -123,4 +136,4 @@ export function useTimezone(): TimezoneContextType { return context; } -export { TimezoneContext }; \ No newline at end of file +export { TimezoneContext }; diff --git a/superset-frontend/src/dashboard/components/CssEditor/index.tsx b/superset-frontend/src/dashboard/components/CssEditor/index.tsx index e2748c8b0b1d..3af73ae7b2eb 100644 --- a/superset-frontend/src/dashboard/components/CssEditor/index.tsx +++ b/superset-frontend/src/dashboard/components/CssEditor/index.tsx @@ -22,7 +22,7 @@ import { AntdDropdown } from 'src/components'; import { Menu } from 'src/components/Menu'; import Button from 'src/components/Button'; import { t, styled, SupersetClient } from '@superset-ui/core'; -import ModalTrigger from 'src/components/ModalTrigger'; +import Modal from 'src/components/Modal'; import { CssEditor as AceCssEditor } from 'src/components/AsyncAceEditor'; export interface CssEditorProps { @@ -38,6 +38,8 @@ export type CssEditorState = { css: string; label: string; }>; + showModal: boolean; + isFullscreen: boolean; }; const StyledWrapper = styled.div` ${({ theme }) => ` @@ -54,9 +56,128 @@ const StyledWrapper = styled.div` .css-editor { border: 1px solid ${theme.colors.grayscale.light1}; } + .load-css-template-btn { + font-size: 10px !important; + border-radius: 8px !important; + height: 26px !important; + background-color: ${theme.colors.grayscale.light4} !important; + } + `} +`; + +const MacOSTitleBar = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(180deg, #f0f0f0 0%, #e8e8e8 100%); + border-bottom: 1px solid #d0d0d0; + padding: ${theme.gridUnit}px ${theme.gridUnit * 2}px; + height: 28px; + position: relative; + + .traffic-lights { + display: flex; + gap: ${theme.gridUnit}px; + + .traffic-light { + width: 12px; + height: 12px; + border-radius: 50%; + border: none; + cursor: pointer; + position: relative; + + &.close { + background: #ff5f56; + &:hover { + background: #ff3b30; + } + &::before { + content: 'ร—'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #8b0000; + font-size: 10px; + font-weight: bold; + line-height: 1; + } + } + + &.fullscreen { + background: #28ca42; + padding: 0 !important; + &:hover { + background: #1fb835; + } + &::before { + content: 'โคข'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #006400; + font-size: 8px; + font-weight: bold; + line-height: 1; + } + } + } + } + + .title { + position: absolute; + left: 50%; + transform: translateX(-50%); + font-size: 13px; + font-weight: 500; + color: #333; + margin: 0; + } `} `; +const StyledModal = styled(Modal)<{ isFullscreen?: boolean }>` + ${({ isFullscreen }) => + isFullscreen && + ` + .ant-modal { + max-width: 75vw !important; + width: 75vw !important; + height: 75vh !important; + top: 15vh !important; + padding-bottom: 0 !important; + } + + .ant-modal-content { + height: 100% !important; + display: flex !important; + flex-direction: column !important; + } + + .ant-modal-body { + flex: 1 !important; + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + } + `} + + .ant-modal-header { + display: none !important; + } + + .ant-modal-close { + display: none !important; + } + + .ant-modal-footer { + display: none !important; + } +`; + class CssEditor extends PureComponent { static defaultProps: Partial = { initialCss: '', @@ -67,9 +188,14 @@ class CssEditor extends PureComponent { super(props); this.state = { css: props.initialCss, + showModal: false, + isFullscreen: false, }; this.changeCss = this.changeCss.bind(this); this.changeCssTemplate = this.changeCssTemplate.bind(this); + this.openModal = this.openModal.bind(this); + this.closeModal = this.closeModal.bind(this); + this.toggleFullscreen = this.toggleFullscreen.bind(this); } componentDidMount() { @@ -105,6 +231,18 @@ class CssEditor extends PureComponent { this.changeCss(keyAsString); } + openModal() { + this.setState({ showModal: true }); + } + + closeModal() { + this.setState({ showModal: false, isFullscreen: false }); + } + + toggleFullscreen() { + this.setState(prevState => ({ isFullscreen: !prevState.isFullscreen })); + } + renderTemplateSelector() { if (this.state.templates) { const menu = ( @@ -116,7 +254,9 @@ class CssEditor extends PureComponent { ); return ( - + ); } @@ -125,29 +265,65 @@ class CssEditor extends PureComponent { render() { return ( - + <> + + {this.props.triggerNode} + + + +
+
+

{t('CSS Editor')}

+ {this.renderTemplateSelector()} +
+
{t('Live CSS editor')}
- {this.renderTemplateSelector()}
- } - /> +
+ ); } } diff --git a/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.comparison.test.tsx b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.comparison.test.tsx new file mode 100644 index 000000000000..cac4e4679fcf --- /dev/null +++ b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.comparison.test.tsx @@ -0,0 +1,334 @@ +/** + * 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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '@superset-ui/core'; +import { Provider } from 'react-redux'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { createStore } from 'redux'; +import SliceHeader from './index'; +import { BigNumberComparisonData } from '../../util/getBigNumberComparisonData'; + +// Mock console methods to avoid noise in tests +const originalConsole = console; +beforeAll(() => { + console.group = jest.fn(); + console.log = jest.fn(); + console.groupEnd = jest.fn(); +}); + +afterAll(() => { + console.group = originalConsole.group; + console.log = originalConsole.log; + console.groupEnd = originalConsole.groupEnd; +}); + +// Mock theme +const mockTheme = { + colors: { + primary: { base: '#1890ff' }, + grayscale: { + light2: '#f5f5f5', + light4: '#d9d9d9', + light5: '#fafafa', + }, + text: { label: '#666' }, + }, + typography: { + sizes: { s: 12, m: 14, l: 16 }, + weights: { medium: 500, bold: 600 }, + families: { sansSerif: 'Arial, sans-serif' }, + }, + gridUnit: 4, + borderRadius: 4, +}; + +// Mock store +const mockStore = createStore(() => ({ + dashboardState: {}, + dashboardInfo: { crossFiltersEnabled: false }, + dataMask: {}, +})); + +// Mock UiConfigContext +jest.mock('src/components/UiConfigContext', () => ({ + useUiConfig: () => ({ hideChartControls: false }), +})); + +// Mock DashboardPageIdContext +jest.mock('src/dashboard/containers/DashboardPage', () => ({ + DashboardPageIdContext: React.createContext('test-dashboard-id'), +})); + +const defaultProps = { + slice: { + slice_id: 1, + slice_name: 'Test BigNumber Chart', + viz_type: 'big_number_total', + description: 'Test description', + }, + componentId: 'test-component', + dashboardId: 1, + chartStatus: 'success', + formData: { viz_type: 'big_number_total' }, + width: 400, + height: 300, + filters: {}, + isCached: [false], + cachedDttm: [null], + isExpanded: false, + supersetCanExplore: true, + supersetCanShare: true, + supersetCanCSV: true, + addSuccessToast: jest.fn(), + addDangerToast: jest.fn(), + handleToggleFullSize: jest.fn(), + isFullSize: false, + forceRefresh: jest.fn(), + updateSliceName: jest.fn(), + toggleExpandSlice: jest.fn(), + logExploreChart: jest.fn(), + exportCSV: jest.fn(), + exportXLSX: jest.fn(), + exportPivotCSV: jest.fn(), + exportFullCSV: jest.fn(), + exportFullXLSX: jest.fn(), +}; + +const renderSliceHeader = ( + props = {}, + comparisonData?: BigNumberComparisonData | null, +) => { + const finalProps = { + ...defaultProps, + ...props, + bigNumberComparisonData: comparisonData, + }; + + return render( + + + + + + + , + ); +}; + +describe('SliceHeader BigNumber Comparison Integration', () => { + describe('Comparison indicator rendering', () => { + it('should not render comparison indicator when no comparison data provided', () => { + renderSliceHeader(); + + // Should not find any comparison indicator elements + expect(screen.queryByText('โ†—')).not.toBeInTheDocument(); + expect(screen.queryByText('โ†˜')).not.toBeInTheDocument(); + expect(screen.queryByText('โˆ’')).not.toBeInTheDocument(); + }); + + it('should render positive comparison indicator correctly', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0.25, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 100, + }; + + renderSliceHeader({}, comparisonData); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('25.0%')).toBeInTheDocument(); + }); + + it('should render negative comparison indicator correctly', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: -0.358, + comparisonIndicator: 'negative', + previousPeriodValue: 65945361.96, + currentValue: 42324187.71, + }; + + renderSliceHeader({}, comparisonData); + + expect(screen.getByText('โ†˜')).toBeInTheDocument(); + expect(screen.getByText('-35.8%')).toBeInTheDocument(); + }); + + it('should render neutral comparison indicator correctly', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0, + comparisonIndicator: 'neutral', + previousPeriodValue: 100, + currentValue: 100, + }; + + renderSliceHeader({}, comparisonData); + + expect(screen.getByText('โˆ’')).toBeInTheDocument(); + expect(screen.getByText('0.0%')).toBeInTheDocument(); + }); + + it('should handle NaN percentage change gracefully', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: NaN, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 100, + }; + + renderSliceHeader({}, comparisonData); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0%')).toBeInTheDocument(); + }); + + it('should handle undefined percentage change gracefully', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: undefined as any, + comparisonIndicator: 'negative', + previousPeriodValue: 80, + currentValue: 100, + }; + + renderSliceHeader({}, comparisonData); + + expect(screen.getByText('โ†˜')).toBeInTheDocument(); + expect(screen.getByText('0%')).toBeInTheDocument(); + }); + }); + + describe('Tooltip functionality', () => { + it('should have tooltip with comparison text', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0.25, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 100, + }; + + renderSliceHeader({}, comparisonData); + + const indicator = screen.getByText('โ†—').closest('[title]'); + expect(indicator).toHaveAttribute( + 'title', + 'Period-over-period comparison', + ); + }); + }); + + describe('Integration with other header elements', () => { + it('should render comparison indicator alongside other header controls', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0.15, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 92, + }; + + renderSliceHeader({}, comparisonData); + + // Should have the comparison indicator + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('15.0%')).toBeInTheDocument(); + + // Should also have the slice name + expect(screen.getByText('Test BigNumber Chart')).toBeInTheDocument(); + }); + + it('should not render comparison indicator in edit mode', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0.25, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 100, + }; + + renderSliceHeader({ editMode: true }, comparisonData); + + // Should not render comparison indicator in edit mode + expect(screen.queryByText('โ†—')).not.toBeInTheDocument(); + expect(screen.queryByText('25.0%')).not.toBeInTheDocument(); + }); + }); + + describe('Loading state', () => { + it('should not render comparison indicator during loading', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0.25, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 100, + }; + + renderSliceHeader({ chartStatus: 'loading' }, comparisonData); + + // Should not render comparison indicator during loading + expect(screen.queryByText('โ†—')).not.toBeInTheDocument(); + expect(screen.queryByText('25.0%')).not.toBeInTheDocument(); + }); + }); + + describe('Edge cases', () => { + it('should handle very large percentage changes', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 10.5, // 1050% increase + comparisonIndicator: 'positive', + previousPeriodValue: 10, + currentValue: 115, + }; + + renderSliceHeader({}, comparisonData); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('1050.0%')).toBeInTheDocument(); + }); + + it('should handle very small percentage changes', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0.001, // 0.1% increase + comparisonIndicator: 'positive', + previousPeriodValue: 1000, + currentValue: 1001, + }; + + renderSliceHeader({}, comparisonData); + + expect(screen.getByText('โ†—')).toBeInTheDocument(); + expect(screen.getByText('0.1%')).toBeInTheDocument(); + }); + + it('should handle invalid comparison indicator gracefully', () => { + const comparisonData: BigNumberComparisonData = { + percentageChange: 0.25, + comparisonIndicator: 'invalid' as any, + previousPeriodValue: 80, + currentValue: 100, + }; + + renderSliceHeader({}, comparisonData); + + // Should not render any comparison indicator for invalid indicator + expect(screen.queryByText('โ†—')).not.toBeInTheDocument(); + expect(screen.queryByText('โ†˜')).not.toBeInTheDocument(); + expect(screen.queryByText('โˆ’')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx index 18582b077c40..d0a3514bbc33 100644 --- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx @@ -22,7 +22,7 @@ import { useContext, useEffect, useRef, - useState + useState, } from 'react'; import { css, @@ -30,6 +30,8 @@ import { styled, t, keyframes, + getNumberFormatter, + NumberFormats, } from '@superset-ui/core'; import { useUiConfig } from 'src/components/UiConfigContext'; import { Tooltip } from 'src/components/Tooltip'; @@ -44,6 +46,14 @@ import { RootState } from 'src/dashboard/types'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage'; +// Inline type definition to avoid import issues +interface BigNumberComparisonData { + percentageChange: number; + comparisonIndicator: 'positive' | 'negative' | 'neutral'; + previousPeriodValue: number; + currentValue: number; +} + const extensionsRegistry = getExtensionsRegistry(); type SliceHeaderProps = SliceHeaderControlsProps & { @@ -58,6 +68,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & { formData: object; width: number; height: number; + bigNumberComparisonData?: BigNumberComparisonData | null; }; const annotationsLoading = t('Annotation layers are still loading.'); @@ -70,6 +81,65 @@ const CrossFilterIcon = styled(Icons.ApartmentOutlined)` `} `; +const ComparisonIndicator = styled.div<{ + indicatorColor: string; +}>` + ${({ theme, indicatorColor }) => ` + display: inline-flex !important; + align-items: center; + gap: ${theme.gridUnit / 2}px; + font-size: ${theme.typography.sizes.s}px; + font-weight: ${theme.typography.weights.medium}; + color: ${indicatorColor} !important; + cursor: help; + white-space: nowrap; + position: relative; + + /* Aggressively remove ALL possible borders and backgrounds */ + background: none !important; + background-color: transparent !important; + background-image: none !important; + border: 0 !important; + border-width: 0 !important; + border-style: none !important; + border-color: transparent !important; + border-top: none !important; + border-right: none !important; + border-bottom: none !important; + border-left: none !important; + padding: 0 !important; + margin: 0 !important; + box-shadow: none !important; + outline: none !important; + + /* Override specific gray border that's being applied */ + border: 0px solid transparent !important; + + /* Target any child elements that might have borders */ + * { + border: none !important; + background: none !important; + box-shadow: none !important; + } + + /* Target the specific class to ensure override */ + &.superset-comparison-indicator-no-border { + border: 0 !important; + background: transparent !important; + background-color: transparent !important; + padding: 0 !important; + box-shadow: none !important; + outline: none !important; + } + + /* Prevent tooltip-induced layout shifts */ + &.ant-tooltip-open { + display: inline-flex !important; + position: relative !important; + } + `} +`; + const shimmer = keyframes` 0% { background-position: 0% 50%; } 100% { background-position: 200% 50%; } @@ -190,6 +260,7 @@ const SliceHeader = forwardRef( formData, width, height, + bigNumberComparisonData, }, ref, ) => { @@ -227,25 +298,114 @@ const SliceHeader = forwardRef( const exploreUrl = `/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${slice.slice_id}`; - if (chartStatus === 'loading') { - return ( - <> - -
-
css` - width: 60%; - min-width: 160px; - `} - > - {/* */} - + // Render comparison indicator for BigNumber charts + const renderComparisonIndicator = () => { + // console.group('๐ŸŽฏ SliceHeader renderComparisonIndicator - DEBUG'); + // console.log('๐Ÿ“Š BigNumber Comparison Data:', { + // bigNumberComparisonData, + // hasBigNumberComparisonData: !!bigNumberComparisonData, + // sliceVizType: slice?.viz_type, + // chartStatus, + // editMode, + // }); + + if (!bigNumberComparisonData) { + // console.log('โŒ No comparison data available - not rendering indicator'); + // console.groupEnd(); + return null; + } + + const { percentageChange, comparisonIndicator } = bigNumberComparisonData; + // console.log('โœ… Rendering comparison indicator:', { + // percentageChange, + // comparisonIndicator, + // percentageChangeType: typeof percentageChange, + // comparisonIndicatorType: typeof comparisonIndicator, + // }); + const formatPercentChange = getNumberFormatter( + NumberFormats.PERCENT_SIGNED_1_POINT, + ); + + let indicatorColor: string; + let arrowIcon: string; + + switch (comparisonIndicator) { + case 'positive': + indicatorColor = '#28a745'; // green + arrowIcon = 'โ†—'; + break; + case 'negative': + indicatorColor = '#dc3545'; // red + arrowIcon = 'โ†˜'; + break; + case 'neutral': + indicatorColor = '#ffc107'; // orange + arrowIcon = 'โˆ’'; + break; + default: + return null; + } + + const tooltipText = t('Period-over-period comparison'); + let formattedPercentage: string; + if (Number.isNaN(percentageChange) || percentageChange === undefined) { + formattedPercentage = '0%'; + } else if (percentageChange === 0) { + // For zero percentage, don't show any sign + formattedPercentage = '0%'; + } else { + formattedPercentage = formatPercentChange(percentageChange); + } + + // console.log('๐ŸŽจ Creating comparison indicator element:', { + // indicatorColor, + // arrowIcon, + // formattedPercentage, + // tooltipText, + // }); + // console.groupEnd(); + + return ( + + + {arrowIcon} + {formattedPercentage} + + + ); + }; + + if (chartStatus === 'loading') { + return ( + <> + +
+
css` + width: 60%; + min-width: 160px; + `} + > + {/* */} + +
-
- - - ); - } + + + ); + } return (
@@ -263,45 +423,45 @@ const SliceHeader = forwardRef( url={canExplore ? exploreUrl : undefined} /> -
css` - display: flex; - align-items: center; - justify-content: space-between; - gap: ${theme.gridUnit * 1.5}px; - margin-top: ${theme.gridUnit / 2}px; - margin-left: ${theme.gridUnit * 1.5}px; - transform: scale(0.95); - `} - > -

css` - margin: 0; - font-size: ${theme.typography.sizes.m}px; display: flex; align-items: center; - gap: ${theme.gridUnit}px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 0; + justify-content: space-between; + gap: ${theme.gridUnit * 1.5}px; + margin-top: ${theme.gridUnit / 2}px; + margin-left: ${theme.gridUnit * 1.5}px; + transform: scale(0.95); `} > - {slice.description?.trim() && ( - - - - )} -

-
+

css` + margin: 0; + font-size: ${theme.typography.sizes.m}px; + display: flex; + align-items: center; + gap: ${theme.gridUnit}px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 0; + `} + > + {slice.description?.trim() && ( + + + + )} +

+
{!!Object.values(annotationQuery).length && ( ( dashboardId={dashboardId} /> )} + {renderComparisonIndicator()} {crossFilterValue && ( { expect(props.addSuccessToast).toHaveBeenCalledTimes(1); }); +test('Should hide "Force refresh" when feature flag is disabled', () => { + // Mock the feature flag to be disabled + jest + .spyOn(require('@superset-ui/core'), 'isFeatureEnabled') + .mockReturnValue(false); + + const props = createProps(); + renderWrapper(props); + + // Open the dropdown menu + userEvent.click(screen.getByRole('button', { name: 'More Options' })); + + // Verify Force refresh option is not present + expect(screen.queryByText('Force refresh')).not.toBeInTheDocument(); + + // Verify other menu items are still present + expect(screen.getByText('Enter fullscreen')).toBeInTheDocument(); + + // Restore the mock + jest.restoreAllMocks(); +}); + test('Should "Enter fullscreen"', () => { const props = createProps(); renderWrapper(props); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 39734aacefc1..bfe79d22500f 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -184,6 +184,10 @@ const SliceHeaderControls = ( ?.behaviors?.includes(Behavior.InteractiveChart); const canExplore = props.supersetCanExplore; const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions(); + const isForceRefreshEnabled = isFeatureEnabled( + FeatureFlag.EnableChartForceRefresh, + ); + const refreshChart = () => { if (props.updatedDttm) { props.forceRefresh(props.slice.slice_id, props.dashboardId); @@ -199,8 +203,10 @@ const SliceHeaderControls = ( }) => { switch (key) { case MenuKeys.ForceRefresh: - refreshChart(); - props.addSuccessToast(t('Data refreshed')); + if (isForceRefreshEnabled) { + refreshChart(); + props.addSuccessToast(t('Data refreshed')); + } break; case MenuKeys.ToggleChartDescription: // eslint-disable-next-line no-unused-expressions @@ -355,17 +361,19 @@ const SliceHeaderControls = ( forceSubMenuRender {...openKeysProps} > - - {t('Force refresh')} - - {refreshTooltip} - - + {isForceRefreshEnabled && ( + + {t('Force refresh')} + + {refreshTooltip} + + + )} {fullscreenLabel} diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.comparison.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.comparison.test.jsx new file mode 100644 index 000000000000..336f850dcfb2 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.comparison.test.jsx @@ -0,0 +1,345 @@ +/** + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { createStore } from 'redux'; +import { ThemeProvider } from '@superset-ui/core'; +import Chart from './Chart'; +import * as getBigNumberComparisonDataModule from '../../util/getBigNumberComparisonData'; + +// Mock console methods to avoid noise in tests +const originalConsole = console; +beforeAll(() => { + console.group = jest.fn(); + console.log = jest.fn(); + console.groupEnd = jest.fn(); +}); + +afterAll(() => { + console.group = originalConsole.group; + console.log = originalConsole.log; + console.groupEnd = originalConsole.groupEnd; +}); + +// Mock the utility function +const mockGetBigNumberComparisonData = jest.spyOn( + getBigNumberComparisonDataModule, + 'getBigNumberComparisonData', +); + +// Mock ChartContainer to avoid complex rendering +jest.mock( + 'src/components/Chart/ChartContainer', + () => + function MockChartContainer() { + return
Chart Container
; + }, +); + +// Mock SliceHeader to verify props are passed correctly +const MockSliceHeader = jest.fn(({ bigNumberComparisonData }) => ( +
+ Slice Header +
+)); + +jest.mock('../SliceHeader', () => MockSliceHeader); + +// Mock theme +const mockTheme = { + colors: { + primary: { base: '#1890ff' }, + grayscale: { light5: '#fafafa' }, + }, + typography: { + sizes: { s: 12 }, + weights: { medium: 500 }, + families: { sansSerif: 'Arial, sans-serif' }, + }, + gridUnit: 4, + borderRadius: 4, +}; + +// Mock store +const mockStore = createStore(() => ({})); + +const defaultProps = { + id: 1, + componentId: 'test-component', + dashboardId: 1, + width: 400, + height: 300, + chart: { + chartStatus: 'success', + queriesResponse: [], + chartUpdateEndTime: Date.now(), + }, + slice: { + slice_id: 1, + slice_name: 'Test Chart', + viz_type: 'big_number_total', + }, + datasource: { id: 1 }, + formData: { viz_type: 'big_number_total' }, + filters: {}, + updateSliceName: jest.fn(), + sliceName: 'Test Chart', + toggleExpandSlice: jest.fn(), + timeout: 30, + supersetCanExplore: true, + supersetCanShare: true, + supersetCanCSV: true, + addSuccessToast: jest.fn(), + addDangerToast: jest.fn(), + ownState: {}, + filterState: {}, + handleToggleFullSize: jest.fn(), + isFullSize: false, + setControlValue: jest.fn(), + postTransformProps: {}, + datasetsStatus: 'complete', + isInView: true, + emitCrossFilters: jest.fn(), + logEvent: jest.fn(), + isExpanded: false, + editMode: false, + labelsColor: {}, + labelsColorMap: {}, +}; + +const renderChart = (props = {}) => { + const finalProps = { ...defaultProps, ...props }; + + return render( + + + + + + + , + ); +}; + +describe('Chart Component BigNumber Comparison Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + MockSliceHeader.mockClear(); + }); + + describe('Comparison data extraction', () => { + it('should call getBigNumberComparisonData with correct parameters', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': 80 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { viz_type: 'big_number_total', metric: 'Gross Sale' }; + + mockGetBigNumberComparisonData.mockReturnValue({ + percentageChange: 0.25, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 100, + }); + + renderChart({ + chart: { ...defaultProps.chart, queriesResponse }, + formData, + }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalledWith( + queriesResponse, + formData, + ); + }); + + it('should pass comparison data to SliceHeader', () => { + const comparisonData = { + percentageChange: 0.25, + comparisonIndicator: 'positive', + previousPeriodValue: 80, + currentValue: 100, + }; + + mockGetBigNumberComparisonData.mockReturnValue(comparisonData); + + renderChart(); + + expect(MockSliceHeader).toHaveBeenCalledWith( + expect.objectContaining({ + bigNumberComparisonData: comparisonData, + }), + {}, + ); + }); + + it('should pass null comparison data when no data available', () => { + mockGetBigNumberComparisonData.mockReturnValue(null); + + renderChart(); + + expect(MockSliceHeader).toHaveBeenCalledWith( + expect.objectContaining({ + bigNumberComparisonData: null, + }), + {}, + ); + }); + }); + + describe('Different chart types', () => { + it('should extract comparison data for big_number_total charts', () => { + const formData = { viz_type: 'big_number_total' }; + + renderChart({ formData }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalledWith( + expect.any(Array), + formData, + ); + }); + + it('should extract comparison data for big_number charts', () => { + const formData = { viz_type: 'big_number' }; + + renderChart({ + formData, + slice: { ...defaultProps.slice, viz_type: 'big_number' }, + }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalledWith( + expect.any(Array), + formData, + ); + }); + + it('should still call extraction function for non-BigNumber charts', () => { + const formData = { viz_type: 'table' }; + + renderChart({ + formData, + slice: { ...defaultProps.slice, viz_type: 'table' }, + }); + + // Function should still be called, but will return null internally + expect(mockGetBigNumberComparisonData).toHaveBeenCalledWith( + expect.any(Array), + formData, + ); + }); + }); + + describe('Chart status handling', () => { + it('should extract comparison data even when chart is loading', () => { + renderChart({ + chart: { ...defaultProps.chart, chartStatus: 'loading' }, + }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalled(); + }); + + it('should extract comparison data when chart has error', () => { + renderChart({ + chart: { ...defaultProps.chart, chartStatus: 'failed' }, + }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalled(); + }); + + it('should extract comparison data when chart is successful', () => { + renderChart({ + chart: { ...defaultProps.chart, chartStatus: 'success' }, + }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('should handle getBigNumberComparisonData throwing an error', () => { + mockGetBigNumberComparisonData.mockImplementation(() => { + throw new Error('Test error'); + }); + + // Should not throw and should render without comparison data + expect(() => renderChart()).not.toThrow(); + + expect(MockSliceHeader).toHaveBeenCalledWith( + expect.objectContaining({ + bigNumberComparisonData: undefined, // Will be undefined due to error + }), + {}, + ); + }); + + it('should handle missing queriesResponse gracefully', () => { + renderChart({ + chart: { ...defaultProps.chart, queriesResponse: undefined }, + }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalledWith( + undefined, + expect.any(Object), + ); + }); + + it('should handle missing formData gracefully', () => { + renderChart({ formData: undefined }); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalledWith( + expect.any(Array), + undefined, + ); + }); + }); + + describe('Performance considerations', () => { + it('should only call getBigNumberComparisonData once per render', () => { + renderChart(); + + expect(mockGetBigNumberComparisonData).toHaveBeenCalledTimes(1); + }); + + it('should call getBigNumberComparisonData on every render (no memoization)', () => { + const { rerender } = renderChart(); + + // Re-render with same props + rerender( + + + + + + + , + ); + + // Should be called twice (once for each render) + expect(mockGetBigNumberComparisonData).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx index 97023cf2036d..f63bf5439d25 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx @@ -19,7 +19,7 @@ import cx from 'classnames'; import { useCallback, useEffect, useRef, useMemo, useState, memo } from 'react'; import PropTypes from 'prop-types'; -import { styled, t, logging } from '@superset-ui/core'; +import { styled, t, logging, getMetricLabel } from '@superset-ui/core'; import { debounce } from 'lodash'; import { useHistory } from 'react-router-dom'; import { bindActionCreators } from 'redux'; @@ -58,6 +58,8 @@ import { } from '../../util/activeDashboardFilters'; import getFormDataWithExtraFilters from '../../util/charts/getFormDataWithExtraFilters'; import { PLACEHOLDER_DATASOURCE } from '../../constants'; +import { slicePropShape, chartPropShape } from '../../util/propShapes'; +// import { getBigNumberComparisonData } from '../../util/getBigNumberComparisonData'; const propTypes = { id: PropTypes.number.isRequired, @@ -428,6 +430,202 @@ const Chart = props => { // eslint-disable-next-line camelcase queriesResponse?.map(({ cached_dttm }) => cached_dttm) || []; + // Extract comparison data for BigNumber charts (inline implementation) + console.group('๐Ÿ“Š Chart Component - BigNumber Comparison Data Extraction'); + console.log('๐Ÿ” Input data for comparison extraction:', { + sliceVizType: slice?.viz_type, + hasQueriesResponse: !!queriesResponse, + queriesResponseLength: queriesResponse?.length || 0, + hasFormData: !!formData, + formDataVizType: formData?.viz_type, + chartStatus, + }); + + let bigNumberComparisonData = null; + + // Simplified inline comparison data extraction + if ( + queriesResponse && + queriesResponse.length > 0 && + formData && + queriesResponse[0] + ) { + const queryResult = queriesResponse[0]; + const { data = [], colnames = [] } = queryResult; + const vizType = formData?.viz_type; + + if (data && data.length > 0 && vizType && vizType.includes('big_number')) { + // Check for time offset columns + const hasTimeOffsetColumns = + colnames && + colnames.length > 0 && + colnames.some(col => col.includes('__') && col !== formData.metric); + + if (hasTimeOffsetColumns) { + // Use EXACT same logic as BigNumber transformProps + const metric = formData.metric || 'value'; + const metricName = getMetricLabel(metric); + + // Use parseMetricValue like BigNumber transformProps does + const parseMetricValue = metricValue => { + if (typeof metricValue === 'string') { + // Handle string dates/numbers + const parsed = parseFloat(metricValue); + return isNaN(parsed) ? null : parsed; + } + return metricValue; + }; + + // Extract current value EXACTLY like BigNumber transformProps (line 64) + const currentValue = + !data || data.length === 0 || !data[0] + ? null + : parseMetricValue(data[0][metricName]); + + console.log('๐Ÿ”ง Chart.jsx - Metric Resolution:', { + rawMetric: formData.metric, + rawMetricType: typeof formData.metric, + resolvedMetricName: metricName, + currentValue, + currentValueType: typeof currentValue, + availableColumns: colnames, + firstRowData: data[0], + allDataKeys: data[0] ? Object.keys(data[0]) : [], + dataKeyValues: data[0] ? Object.entries(data[0]) : [], + }); + + // Find previous period value EXACTLY like BigNumber transformProps does + let previousPeriodValue = null; + if (data[0]) { + for (const col of colnames) { + if (col.includes('__') && col !== metricName) { + const rawValue = data[0][col]; + console.log('๐Ÿ” Checking time offset column:', { + col, + rawValue, + rawValueType: typeof rawValue, + isNull: rawValue === null, + isUndefined: rawValue === undefined, + }); + if (rawValue !== null && rawValue !== undefined) { + // Use parseMetricValue like BigNumber transformProps does (line 236) + previousPeriodValue = parseMetricValue(rawValue); + console.log( + 'โœ… Found previousPeriodValue:', + previousPeriodValue, + ); + break; + } + } + } + } + + console.log('๐Ÿ“Š Values before calculation:', { + currentValue, + previousPeriodValue, + currentValueValid: currentValue !== null && !isNaN(currentValue), + previousValueValid: + previousPeriodValue !== null && !isNaN(previousPeriodValue), + }); + + if (previousPeriodValue !== null && !isNaN(previousPeriodValue)) { + let percentageChange = 0; + let comparisonIndicator = 'neutral'; + + if (previousPeriodValue === 0) { + if (currentValue === null || currentValue === 0) { + percentageChange = 0; + comparisonIndicator = 'neutral'; + } else { + percentageChange = currentValue > 0 ? 1 : -1; + comparisonIndicator = currentValue > 0 ? 'positive' : 'negative'; + } + } else if (currentValue === null || currentValue === 0) { + percentageChange = -1; // -100% change (complete loss) + comparisonIndicator = 'negative'; + } else if (!isNaN(currentValue)) { + percentageChange = + (currentValue - previousPeriodValue) / + Math.abs(previousPeriodValue); + comparisonIndicator = + percentageChange > 0 + ? 'positive' + : percentageChange < 0 + ? 'negative' + : 'neutral'; + } + + console.log('๐Ÿงฎ Chart.jsx - Final calculation:', { + currentValue, + previousPeriodValue, + difference: (currentValue || 0) - previousPeriodValue, + percentageChange, + comparisonIndicator, + isNaN: isNaN(percentageChange), + }); + + bigNumberComparisonData = { + percentageChange, + comparisonIndicator, + previousPeriodValue, + currentValue: currentValue || 0, // Use 0 if currentValue is null + }; + } + } + } + } + + console.log('๐Ÿ“ˆ Comparison data extraction result:', { + bigNumberComparisonData, + hasBigNumberComparisonData: !!bigNumberComparisonData, + willPassToSliceHeader: true, + }); + console.groupEnd(); + + // + // return ( { isCached={isCached} cachedDttm={cachedDttm} updatedDttm={chartUpdateEndTime} - toggleExpandSlice={boundActionCreators.toggleExpandSlice} + toggleExpandSlice={toggleExpandSlice} forceRefresh={forceRefresh} editMode={editMode} annotationQuery={annotationQuery} @@ -529,8 +727,8 @@ const Chart = props => { datasetsStatus={datasetsStatus} isInView={props.isInView} emitCrossFilters={emitCrossFilters} - description={slice.description} - title={slice.slice_name} + description={slice.description} + title={slice.slice_name} /> diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx index 064be716861a..efc7cdf50f7b 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.test.tsx @@ -194,8 +194,6 @@ describe('FilterBar', () => { expect(screen.getByText('Clear all')).toBeInTheDocument(); }); - - it('should render the collapse icon', () => { renderWrapper(); expect(screen.getByRole('img', { name: 'collapse' })).toBeInTheDocument(); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx index 05902a154a43..dcab5c90a116 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Horizontal.tsx @@ -34,7 +34,8 @@ import crossFiltersSelector from './CrossFilters/selectors'; const HorizontalBar = styled.div` ${({ theme }) => ` - padding: ${theme.gridUnit * 3}px ${theme.gridUnit * 2}px ${theme.gridUnit * 3 + padding: ${theme.gridUnit * 3}px ${theme.gridUnit * 2}px ${ + theme.gridUnit * 3 }px ${theme.gridUnit * 4}px; background: ${theme.colors.grayscale.light5}; box-shadow: inset 0px -2px 2px -1px ${theme.colors.grayscale.light2}; @@ -100,14 +101,12 @@ const HorizontalFilterBar: FC = ({ isInitialized, onSelectionChange, }) => { - const overallState = useSelector(state => state) as any; const isIframe = window.self !== window.top; const dashboardInfo = useSelector( state => state.dashboardInfo, ); - const dataMask = useSelector( state => state.dataMask, ); @@ -180,7 +179,6 @@ const HorizontalFilterBar: FC = ({ )} {actions} - ); diff --git a/superset-frontend/src/dashboard/util/getBigNumberComparisonData.test.ts b/superset-frontend/src/dashboard/util/getBigNumberComparisonData.test.ts new file mode 100644 index 000000000000..98400c1a5b75 --- /dev/null +++ b/superset-frontend/src/dashboard/util/getBigNumberComparisonData.test.ts @@ -0,0 +1,362 @@ +/** + * 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 { getBigNumberComparisonData } from './getBigNumberComparisonData'; + +// Mock console methods to avoid noise in tests +const originalConsole = console; +beforeAll(() => { + console.group = jest.fn(); + console.log = jest.fn(); + console.groupEnd = jest.fn(); +}); + +afterAll(() => { + console.group = originalConsole.group; + console.log = originalConsole.log; + console.groupEnd = originalConsole.groupEnd; +}); + +describe('getBigNumberComparisonData', () => { + describe('Input validation', () => { + it('should return null when queriesResponse is null or undefined', () => { + expect(getBigNumberComparisonData(null, {})).toBeNull(); + expect(getBigNumberComparisonData(undefined, {})).toBeNull(); + expect(getBigNumberComparisonData([], {})).toBeNull(); + }); + + it('should return null when formData is null or undefined', () => { + const queriesResponse = [ + { data: [{ 'Gross Sale': 100 }], colnames: ['Gross Sale'] }, + ]; + expect(getBigNumberComparisonData(queriesResponse, null)).toBeNull(); + expect(getBigNumberComparisonData(queriesResponse, undefined)).toBeNull(); + }); + + it('should return null when data is empty', () => { + const queriesResponse = [{ data: [], colnames: [] }]; + const formData = { viz_type: 'big_number_total', metric: 'Gross Sale' }; + expect(getBigNumberComparisonData(queriesResponse, formData)).toBeNull(); + }); + + it('should return null for non-BigNumber charts', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': 80 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'table', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + expect(getBigNumberComparisonData(queriesResponse, formData)).toBeNull(); + }); + }); + + describe('BigNumber chart detection', () => { + it('should process big_number_total charts', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': 80 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + expect(result).not.toBeNull(); + }); + + it('should process big_number charts', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': 80 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + expect(result).not.toBeNull(); + }); + }); + + describe('Time comparison detection', () => { + const baseQueriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': 80 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + + it('should detect time_compare from formData.time_compare', () => { + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: '1 day ago', + }; + const result = getBigNumberComparisonData(baseQueriesResponse, formData); + expect(result).not.toBeNull(); + expect(result?.percentageChange).toBe(0.25); // (100-80)/80 = 0.25 + }); + + it('should detect time_compare from extra_form_data', () => { + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + extra_form_data: { time_compare: '1 day ago' }, + }; + const result = getBigNumberComparisonData(baseQueriesResponse, formData); + expect(result).not.toBeNull(); + }); + + it('should force inherit when time offset columns exist but no time_compare', () => { + const formData = { viz_type: 'big_number_total', metric: 'Gross Sale' }; + const result = getBigNumberComparisonData(baseQueriesResponse, formData); + expect(result).not.toBeNull(); + }); + + it('should return null when time_compare is custom', () => { + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'custom', + }; + const result = getBigNumberComparisonData(baseQueriesResponse, formData); + expect(result).toBeNull(); + }); + }); + + describe('Percentage calculation', () => { + it('should calculate positive percentage change correctly', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 120, 'Gross Sale__1 day ago': 100 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.percentageChange).toBe(0.2); // (120-100)/100 = 0.2 + expect(result?.comparisonIndicator).toBe('positive'); + expect(result?.currentValue).toBe(120); + expect(result?.previousPeriodValue).toBe(100); + }); + + it('should calculate negative percentage change correctly', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 80, 'Gross Sale__1 day ago': 100 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.percentageChange).toBe(-0.2); // (80-100)/100 = -0.2 + expect(result?.comparisonIndicator).toBe('negative'); + }); + + it('should handle neutral change (no change)', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': 100 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.percentageChange).toBe(0); + expect(result?.comparisonIndicator).toBe('neutral'); + }); + }); + + describe('Edge cases', () => { + it('should handle previous period value of 0 with positive current value', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': 0 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.percentageChange).toBe(1); // 100% change (maximum) + expect(result?.comparisonIndicator).toBe('positive'); + }); + + it('should handle current value of 0 with non-zero previous value', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 0, 'Gross Sale__1 day ago': 100 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.percentageChange).toBe(-1); // -100% change (complete loss) + expect(result?.comparisonIndicator).toBe('negative'); + }); + + it('should handle both values being 0', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 0, 'Gross Sale__1 day ago': 0 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.percentageChange).toBe(0); + expect(result?.comparisonIndicator).toBe('neutral'); + }); + + it('should return null when no time offset columns found', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100 }], + colnames: ['Gross Sale'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).toBeNull(); + }); + + it('should return null when current value is null', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': null, 'Gross Sale__1 day ago': 100 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).toBeNull(); + }); + + it('should return null when previous period value is null', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': null }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).toBeNull(); + }); + }); + + describe('Metric handling', () => { + it('should handle string metric values by parsing them', () => { + const queriesResponse = [ + { + data: [{ 'Gross Sale': 100, 'Gross Sale__1 day ago': '80' }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + metric: 'Gross Sale', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.previousPeriodValue).toBe(80); + expect(result?.percentageChange).toBe(0.25); + }); + + it('should use default metric name when not provided', () => { + const queriesResponse = [ + { + data: [{ value: 100, 'value__1 day ago': 80 }], + colnames: ['value', 'value__1 day ago'], + }, + ]; + const formData = { + viz_type: 'big_number_total', + time_compare: 'inherit', + }; + const result = getBigNumberComparisonData(queriesResponse, formData); + + expect(result).not.toBeNull(); + expect(result?.currentValue).toBe(100); + }); + }); +}); diff --git a/superset-frontend/src/dashboard/util/getBigNumberComparisonData.ts b/superset-frontend/src/dashboard/util/getBigNumberComparisonData.ts new file mode 100644 index 000000000000..1aaa29eaebe6 --- /dev/null +++ b/superset-frontend/src/dashboard/util/getBigNumberComparisonData.ts @@ -0,0 +1,204 @@ +/** + * 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 { getMetricLabel } from '@superset-ui/core'; + +export interface BigNumberComparisonData { + percentageChange: number; + comparisonIndicator: 'positive' | 'negative' | 'neutral'; + previousPeriodValue: number; + currentValue: number; +} + +/** + * Extracts comparison data from BigNumber chart queries response + * This replicates the logic from BigNumber transformProps but for use in SliceHeader + */ +export function getBigNumberComparisonData( + queriesResponse: any[], + formData: any, +): BigNumberComparisonData | null { + console.group('๐Ÿ”ง getBigNumberComparisonData - COMPREHENSIVE DEBUG'); + console.log('๐Ÿ“ฅ Input Analysis:', { + hasQueriesResponse: !!queriesResponse, + queriesResponseLength: queriesResponse?.length || 0, + hasFormData: !!formData, + formDataKeys: formData ? Object.keys(formData) : [], + vizType: formData?.viz_type, + }); + + if (!queriesResponse || queriesResponse.length === 0) { + console.log('โŒ No queriesResponse available'); + console.groupEnd(); + return null; + } + + const { data = [], colnames = [] } = queriesResponse[0]; + console.log('๐Ÿ“Š Query Data Analysis:', { + hasData: !!data, + dataLength: data?.length || 0, + hasColnames: !!colnames, + colnamesLength: colnames?.length || 0, + colnames, + firstRowData: data?.[0], + }); + + if (!data || data.length === 0) { + console.log('โŒ No data available in queriesResponse'); + console.groupEnd(); + return null; + } + + const metric = formData?.metric || 'value'; + const metricName = getMetricLabel(metric); + + console.log('๐ŸŽฏ Metric Analysis:', { + metric, + metricName, + metricType: typeof metric, + }); + + // Check if this is a BigNumber chart with time comparison + const vizType = formData?.viz_type; + console.log('๐Ÿ“‹ Chart Type Check:', { + vizType, + isBigNumberChart: vizType && vizType.includes('big_number'), + }); + + if (!vizType || !vizType.includes('big_number')) { + console.log( + 'โŒ Not a BigNumber chart - skipping comparison data extraction', + ); + console.groupEnd(); + return null; + } + + // Check for time comparison + let timeCompare = + formData.time_compare || + (formData.extra_form_data?.custom_form_data as any)?.time_compare || + (formData.extra_form_data as any)?.time_compare; + + // Check for time-offset columns + const hasTimeOffsetColumns = colnames?.some( + (col: string) => col.includes('__') && col !== metricName, + ); + + if (!timeCompare && hasTimeOffsetColumns) { + timeCompare = 'inherit'; + } + + console.log('โฐ Time Comparison Analysis:', { + timeCompare, + hasTimeOffsetColumns, + timeCompareSource: formData.time_compare + ? 'formData.time_compare' + : (formData.extra_form_data?.custom_form_data as any)?.time_compare + ? 'custom_form_data' + : (formData.extra_form_data as any)?.time_compare + ? 'extra_form_data' + : hasTimeOffsetColumns + ? 'forced_inherit' + : 'none', + }); + + if (!timeCompare || timeCompare === 'custom') { + console.log('โŒ No valid time comparison found - skipping'); + console.groupEnd(); + return null; + } + + // Get current period value + const currentValue = data[0][metricName]; + console.log('๐Ÿ“Š Current Value Analysis:', { + currentValue, + currentValueType: typeof currentValue, + metricName, + hasCurrentValue: currentValue !== null && currentValue !== undefined, + }); + + if (currentValue === null || currentValue === undefined) { + console.log('โŒ No current value available'); + console.groupEnd(); + return null; + } + + // Find time offset columns and extract previous period value + let previousPeriodValue: number | null = null; + + for (const col of colnames) { + if (col.includes('__') && col !== metricName) { + const offsetCol = col; + const rawValue = data[0][offsetCol]; + + if (rawValue !== null && rawValue !== undefined) { + previousPeriodValue = + typeof rawValue === 'number' ? rawValue : parseFloat(rawValue); + break; + } + } + } + + if (previousPeriodValue === null || previousPeriodValue === undefined) { + return null; + } + + // Calculate percentage change and indicator + let percentageChange: number; + let comparisonIndicator: 'positive' | 'negative' | 'neutral'; + + if (previousPeriodValue === 0) { + if (currentValue === 0) { + percentageChange = 0; + comparisonIndicator = 'neutral'; + } else if (currentValue > 0) { + percentageChange = 1; // 100% change as maximum + comparisonIndicator = 'positive'; + } else { + percentageChange = -1; // -100% change as minimum + comparisonIndicator = 'negative'; + } + } else if (currentValue === 0) { + percentageChange = -1; // -100% change (complete loss) + comparisonIndicator = 'negative'; + } else { + percentageChange = + (currentValue - previousPeriodValue) / Math.abs(previousPeriodValue); + + if (percentageChange > 0) { + comparisonIndicator = 'positive'; + } else if (percentageChange < 0) { + comparisonIndicator = 'negative'; + } else { + comparisonIndicator = 'neutral'; + } + } + + const result = { + percentageChange, + comparisonIndicator, + previousPeriodValue, + currentValue, + }; + + console.log('โœ… FINAL COMPARISON RESULT:', result); + console.groupEnd(); + + return result; +} diff --git a/superset-frontend/src/explore/components/EmbedCodeContent.jsx b/superset-frontend/src/explore/components/EmbedCodeContent.jsx index 504ef78ed633..d6240228b280 100644 --- a/superset-frontend/src/explore/components/EmbedCodeContent.jsx +++ b/superset-frontend/src/explore/components/EmbedCodeContent.jsx @@ -67,7 +67,10 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => { const html = useMemo(() => { if (!url) return ''; - const timezoneParam = timezone !== 'UTC' ? `&${URL_PARAMS.timezone.name}=${encodeURIComponent(timezone)}` : ''; + const timezoneParam = + timezone !== 'UTC' + ? `&${URL_PARAMS.timezone.name}=${encodeURIComponent(timezone)}` + : ''; const srcLink = `${url}?${URL_PARAMS.standalone.name}=1&height=${height}${timezoneParam}`; return ( ' css` padding: ${theme.gridUnit * 1.5}px ${theme.gridUnit * 4}px - ${theme.gridUnit * 4}px ${theme.gridUnit * 7}px; + ${theme.gridUnit * 1.5}px ${theme.gridUnit * 4}px; color: ${theme.colors.grayscale.base}; font-size: ${theme.typography.sizes.xs}px; white-space: nowrap; @@ -113,6 +113,103 @@ const StyledSubMenu = styled(SubMenu)` color: ${({ theme }) => theme.colors.primary.base}; } } + + /* Enhanced dropdown styling for better appearance */ + .antd5-menu-submenu-title { + display: flex; + align-items: center; + padding: ${({ theme }) => theme.gridUnit * 2}px + ${({ theme }) => theme.gridUnit * 4}px; + + /* Fix icon alignment and size */ + .anticon { + margin-left: ${({ theme }) => theme.gridUnit}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; + } + } + + /* Improve dropdown menu appearance */ + .ant-dropdown-menu, + .antd5-menu-vertical { + min-width: 180px; + max-width: 250px; + border-radius: ${({ theme }) => theme.borderRadius}px; + box-shadow: + 0 3px 6px -4px ${({ theme }) => theme.colors.grayscale.dark2}20, + 0 6px 16px 0 ${({ theme }) => theme.colors.grayscale.dark2}14, + 0 9px 28px 8px ${({ theme }) => theme.colors.grayscale.dark2}0d; + border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + padding: ${({ theme }) => theme.gridUnit * 2}px 0; + } + + /* Fix menu item group styling - match menu item padding */ + .antd5-menu-item-group-title { + color: ${({ theme }) => theme.colors.grayscale.base}; + font-weight: ${({ theme }) => theme.typography.weights.bold}; + font-size: ${({ theme }) => theme.typography.sizes.s}px; + padding: ${({ theme }) => theme.gridUnit * 1.5}px + ${({ theme }) => theme.gridUnit * 4}px ${({ theme }) => theme.gridUnit}px; + margin-bottom: ${({ theme }) => theme.gridUnit / 2}px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + /* Improve menu item styling */ + .antd5-menu-item { + padding: ${({ theme }) => theme.gridUnit * 1.5}px + ${({ theme }) => theme.gridUnit * 4}px; + line-height: 1.5; + margin: 0 ${({ theme }) => theme.gridUnit}px + ${({ theme }) => theme.gridUnit / 2}px; + border-radius: ${({ theme }) => theme.borderRadius / 2}px; + + a { + color: ${({ theme }) => theme.colors.grayscale.dark1}; + text-decoration: none; + display: block; + padding: ${({ theme }) => theme.gridUnit / 2}px 0; + + &:hover { + color: ${({ theme }) => theme.colors.primary.base}; + } + } + + &:hover { + background-color: ${({ theme }) => theme.colors.primary.light5}; + } + } + + /* Special styling for submenu items within groups */ + .antd5-menu-item-group-list .antd5-menu-item { + padding-left: ${({ theme }) => theme.gridUnit * 6}px; + font-size: ${({ theme }) => theme.typography.sizes.s}px; + } + + /* Fix divider styling */ + .antd5-menu-item-divider { + margin: ${({ theme }) => theme.gridUnit}px + ${({ theme }) => theme.gridUnit}px; + background-color: ${({ theme }) => theme.colors.grayscale.light2}; + height: 1px; + } + + /* Fix alignment issues for menu groups */ + .antd5-menu-item-group { + margin-bottom: ${({ theme }) => theme.gridUnit}px; + + &:last-child { + margin-bottom: 0; + } + } + + /* Add proper spacing for the about section */ + .about-section { + margin-bottom: ${({ theme }) => theme.gridUnit * 2}px; + + div:last-child { + margin-bottom: ${({ theme }) => theme.gridUnit * 2}px; + } + } `; const RightMenu = ({ @@ -473,10 +570,7 @@ const RightMenu = ({ })} )} - } - > + }> {settings?.map?.((section, index) => [ {section?.childs?.map?.(child => { diff --git a/superset-frontend/src/hooks/useTimezoneConversion.ts b/superset-frontend/src/hooks/useTimezoneConversion.ts index 16a2fde3eb69..356de4f78a3a 100644 --- a/superset-frontend/src/hooks/useTimezoneConversion.ts +++ b/superset-frontend/src/hooks/useTimezoneConversion.ts @@ -25,18 +25,18 @@ import { useTimezone } from 'src/components/TimezoneContext'; * Provides utilities to convert dates between timezone and UTC for API calls */ export function useTimezoneConversion() { - const { timezone, convertToUTC, convertFromUTC, formatDate, formatDateTime } = useTimezone(); + const { timezone, convertToUTC, convertFromUTC, formatDate, formatDateTime } = + useTimezone(); /** * Convert date range filters to UTC for API calls * This should be used when sending date filters to the backend */ - const convertDateRangeToUTC = (dateRange: [string, string]): [string, string] => { + const convertDateRangeToUTC = ( + dateRange: [string, string], + ): [string, string] => { const [start, end] = dateRange; - return [ - convertToUTC(start).toISOString(), - convertToUTC(end).toISOString(), - ]; + return [convertToUTC(start).toISOString(), convertToUTC(end).toISOString()]; }; /** @@ -65,7 +65,7 @@ export function useTimezoneConversion() { if (Array.isArray(converted[field])) { // Handle date range arrays converted[field] = converted[field].map((date: any) => - typeof date === 'string' ? convertToUTC(date).toISOString() : date + typeof date === 'string' ? convertToUTC(date).toISOString() : date, ); } else if (typeof converted[field] === 'string') { // Handle single date strings @@ -140,16 +140,18 @@ export function useTimezoneConversion() { /** * Format a date for display in the current timezone */ - const formatDateForDisplay = (date: moment.MomentInput, format?: string): string => { - return formatDate(date, format); - }; + const formatDateForDisplay = ( + date: moment.MomentInput, + format?: string, + ): string => formatDate(date, format); /** * Format a datetime for display in the current timezone */ - const formatDateTimeForDisplay = (date: moment.MomentInput, format?: string): string => { - return formatDateTime(date, format); - }; + const formatDateTimeForDisplay = ( + date: moment.MomentInput, + format?: string, + ): string => formatDateTime(date, format); /** * Get timezone info for debugging/logging diff --git a/superset-frontend/src/pages/Home/index.tsx b/superset-frontend/src/pages/Home/index.tsx index 6d005c79f6f6..37f2cf65f7e5 100644 --- a/superset-frontend/src/pages/Home/index.tsx +++ b/superset-frontend/src/pages/Home/index.tsx @@ -46,7 +46,7 @@ import { loadingCardCount, mq, } from 'src/views/CRUD/utils'; -import { Switch } from 'src/components/Switch'; +import { Radio, RadioChangeEvent } from 'src/components/Radio'; import getBootstrapData from 'src/utils/getBootstrapData'; import { TableTab } from 'src/views/CRUD/types'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; @@ -124,14 +124,48 @@ const WelcomeContainer = styled.div` const WelcomeNav = styled.div` ${({ theme }) => ` - .switch { + .thumbnail-radio-group { display: flex; flex-direction: row; + align-items: center; margin: ${theme.gridUnit * 4}px; - span { + + .antd5-radio-group { + display: flex; + align-items: center; + + .antd5-radio-wrapper { + margin-right: ${theme.gridUnit * 3}px; + font-size: ${theme.typography.sizes.s}px; + color: ${theme.colors.grayscale.dark1}; + + &:last-child { + margin-right: 0; + } + + .antd5-radio { + margin-right: ${theme.gridUnit}px; + } + + span:not(.antd5-radio) { + font-size: ${theme.typography.sizes.s}px; + color: ${theme.colors.grayscale.dark1}; + } + } + + .antd5-radio-wrapper-checked { + color: ${theme.colors.primary.base}; + font-weight: ${theme.typography.weights.medium}; + } + } + + .radio-group-label { display: block; - margin: ${theme.gridUnit}px; + margin: 0 ${theme.gridUnit * 2}px 0 0; line-height: ${theme.gridUnit * 3.5}px; + font-size: ${theme.typography.sizes.s}px; + color: ${theme.colors.grayscale.dark1}; + font-weight: ${theme.typography.weights.medium}; } } `} @@ -298,9 +332,10 @@ function Welcome({ user, addDangerToast }: WelcomeProps) { }); }, [otherTabFilters]); - const handleToggle = () => { - setChecked(!checked); - dangerouslySetItemDoNotUse(id, { thumbnails: !checked }); + const handleThumbnailChange = (e: RadioChangeEvent) => { + const newValue = e.target.value === 'on'; + setChecked(newValue); + dangerouslySetItemDoNotUse(id, { thumbnails: newValue }); }; useEffect(() => { @@ -336,13 +371,20 @@ function Welcome({ user, addDangerToast }: WelcomeProps) { { name: ( -
- - {t('Thumbnails')} +
+ {t('View:')} + + {t('Thumbnails')} + {t('List')} +
), - onClick: handleToggle, + onClick: undefined, buttonStyle: 'link', }, ]; diff --git a/superset-frontend/src/theme/index.ts b/superset-frontend/src/theme/index.ts index ff8e2aca6a76..df034fd79a1a 100644 --- a/superset-frontend/src/theme/index.ts +++ b/superset-frontend/src/theme/index.ts @@ -108,6 +108,9 @@ const baseConfig: ThemeConfig = { paddingLG: supersetTheme.gridUnit * 6, fontWeightStrong: supersetTheme.typography.weights.medium, colorBgContainer: supersetTheme.colors.grayscale.light4, + borderRadiusLG: supersetTheme.borderRadius, + borderRadius: supersetTheme.borderRadius, + borderRadiusSM: supersetTheme.borderRadius, }, DatePicker: { colorBgContainer: supersetTheme.colors.grayscale.light5, @@ -184,8 +187,18 @@ const baseConfig: ThemeConfig = { iconSizeSM: 20, }, Switch: { - colorPrimaryHover: supersetTheme.colors.primary.base, + colorPrimary: supersetTheme.colors.primary.base, + colorPrimaryHover: supersetTheme.colors.primary.dark1, colorTextTertiary: supersetTheme.colors.grayscale.light1, + colorTextQuaternary: supersetTheme.colors.grayscale.light2, + handleBg: supersetTheme.colors.grayscale.light5, + handleSize: 18, + handleSizeSM: 14, + trackHeight: 22, + trackHeightSM: 16, + trackMinWidth: 44, + trackMinWidthSM: 28, + borderRadius: 100, }, Tooltip: { fontSize: supersetTheme.typography.sizes.s, diff --git a/superset-frontend/src/utils/aiSummary.test.ts b/superset-frontend/src/utils/aiSummary.test.ts index a6103dc34501..6a30cc29cf1c 100644 --- a/superset-frontend/src/utils/aiSummary.test.ts +++ b/superset-frontend/src/utils/aiSummary.test.ts @@ -185,7 +185,8 @@ describe('aiSummary utilities', () => { it('should include URL query parameters', async () => { mockFetch.mockResolvedValueOnce(mockSuccessResponse as any); - window.location.search = '?currency_code=USD&timezone=UTC&country_code=US&country=United States'; + window.location.search = + '?currency_code=USD&timezone=UTC&country_code=US&country=United States'; const input: ChartSummaryInput = { vizType: 'pie', @@ -227,7 +228,9 @@ describe('aiSummary utilities', () => { description: 'This should fail', }; - await expect(generateSummary(input)).rejects.toThrow('AI endpoint error 500'); + await expect(generateSummary(input)).rejects.toThrow( + 'AI endpoint error 500', + ); }); it('should handle invalid response format', async () => { @@ -241,7 +244,9 @@ describe('aiSummary utilities', () => { title: 'Invalid Response Test', }; - await expect(generateSummary(input)).rejects.toThrow('Invalid AI response'); + await expect(generateSummary(input)).rejects.toThrow( + 'Invalid AI response', + ); }); it('should respect timeout option', async () => { @@ -249,7 +254,9 @@ describe('aiSummary utilities', () => { signal: { aborted: false }, abort: jest.fn(), }; - jest.spyOn(global, 'AbortController').mockImplementation(() => mockAbortController as any); + jest + .spyOn(global, 'AbortController') + .mockImplementation(() => mockAbortController as any); jest.spyOn(global, 'setTimeout').mockImplementation((callback, delay) => { if (delay === 5000) { // Call the abort function immediately for testing diff --git a/superset-frontend/src/utils/timezoneApiUtils.ts b/superset-frontend/src/utils/timezoneApiUtils.ts index e97a6867ee17..c1eff0471bcc 100644 --- a/superset-frontend/src/utils/timezoneApiUtils.ts +++ b/superset-frontend/src/utils/timezoneApiUtils.ts @@ -156,7 +156,7 @@ function resolveRelativeTimeRangeInternal( .clone() .startOf(unit as any) .format('YYYY-MM-DDTHH:mm:ss'); - console.log(`Previous range: ${startTime} : ${endTime}`) + console.log(`Previous range: ${startTime} : ${endTime}`); return `${startTime} : ${endTime}`; } const lastMatch = timeRange.match( @@ -178,7 +178,7 @@ function resolveRelativeTimeRangeInternal( .subtract(count, momentUnit as any) .format('YYYY-MM-DDTHH:mm:ss'); const endTime = today.format('YYYY-MM-DDTHH:mm:ss'); - console.log(`Last range: ${startTime} : ${endTime}`) + console.log(`Last range: ${startTime} : ${endTime}`); return `${startTime} : ${endTime}`; } if (timeRange.includes('DATETRUNC(')) { @@ -202,19 +202,28 @@ function resolveRelativeTimeRangeInternal( function resolveRelativeTimeRange(timeRange: string, timezone: string): string { const originalTimeRange = timeRange; if (!isRelativeTimeRange(timeRange)) { - logTimezoneConversion('resolveRelativeTimeRange (not relative)', originalTimeRange, timeRange); + logTimezoneConversion( + 'resolveRelativeTimeRange (not relative)', + originalTimeRange, + timeRange, + ); return timeRange; } const now = moment.tz(timezone); const today = moment.tz(timezone).startOf('day'); - const result = resolveRelativeTimeRangeInternal(timeRange, timezone, now, today); - + const result = resolveRelativeTimeRangeInternal( + timeRange, + timezone, + now, + today, + ); + logTimezoneConversion('resolveRelativeTimeRange', originalTimeRange, result, { timezone, now: now.format('YYYY-MM-DDTHH:mm:ss'), today: today.format('YYYY-MM-DDTHH:mm:ss'), }); - + return result; } @@ -229,8 +238,11 @@ function convertFilterDateValues(filter: any, timezone: string): any { isDateString(converted.comparator) ) { // Skip conversion for relative time ranges in filter comparators - const isSimpleRelativeRange = /^(last|previous|current)\s+(day|week|month|quarter|year)$/i.test(converted.comparator) || - /^previous\s+calendar\s+(week|month|year)$/i.test(converted.comparator); + const isSimpleRelativeRange = + /^(last|previous|current)\s+(day|week|month|quarter|year)$/i.test( + converted.comparator, + ) || + /^previous\s+calendar\s+(week|month|year)$/i.test(converted.comparator); if (!isSimpleRelativeRange) { converted.comparator = convertToUTC( converted.comparator, @@ -324,29 +336,41 @@ export function convertRequestDatesToUTC( if (typeof extraFormData.time_range === 'string') { const originalTimeRange = extraFormData.time_range; let tr = extraFormData.time_range as string; - - logTimezoneConversion('extra_form_data.time_range (initial)', originalTimeRange, tr); - - logTimezoneConversion('extra_form_data.time_range (processing check)', tr, 'WILL PROCESS ALL RANGES'); - + + logTimezoneConversion( + 'extra_form_data.time_range (initial)', + originalTimeRange, + tr, + ); + + logTimezoneConversion( + 'extra_form_data.time_range (processing check)', + tr, + 'WILL PROCESS ALL RANGES', + ); + if (isRelativeTimeRange(tr) && !tr.includes(' : ')) { const resolvedTr = resolveRelativeTimeRange(tr, timezone); - logTimezoneConversion('extra_form_data.time_range (resolved)', tr, resolvedTr); + logTimezoneConversion( + 'extra_form_data.time_range (resolved)', + tr, + resolvedTr, + ); tr = resolvedTr; } - + const parts = tr.split(' : '); if (parts.length === 2) { let [startTime, endTime] = parts; const originalParts = [startTime, endTime]; - + if (isRelativeTimeRange(startTime)) { startTime = resolveRelativeTimeRange(startTime, timezone); } if (isRelativeTimeRange(endTime)) { endTime = resolveRelativeTimeRange(endTime, timezone); } - + if (isDateString(startTime) && isDateString(endTime)) { const convertedStart = convertToUTC( startTime, @@ -354,17 +378,30 @@ export function convertRequestDatesToUTC( ).toISOString(); const convertedEnd = convertToUTC(endTime, timezone).toISOString(); tr = `${convertedStart} : ${convertedEnd}`; - - logTimezoneConversion('extra_form_data.time_range (final UTC)', originalParts, [convertedStart, convertedEnd], { - timezone, - originalTimeRange, - }); + + logTimezoneConversion( + 'extra_form_data.time_range (final UTC)', + originalParts, + [convertedStart, convertedEnd], + { + timezone, + originalTimeRange, + }, + ); } else { tr = `${startTime} : ${endTime}`; - logTimezoneConversion('extra_form_data.time_range (non-date range)', originalParts, [startTime, endTime]); + logTimezoneConversion( + 'extra_form_data.time_range (non-date range)', + originalParts, + [startTime, endTime], + ); } } else { - logTimezoneConversion('extra_form_data.time_range (single value)', originalTimeRange, tr); + logTimezoneConversion( + 'extra_form_data.time_range (single value)', + originalTimeRange, + tr, + ); } extraFormData.time_range = tr; } @@ -384,9 +421,9 @@ export function convertRequestDatesToUTC( ) { logTimezoneConversion('date string conversion', field, { value: converted[field], - isTimeRange: field === 'time_range' + isTimeRange: field === 'time_range', }); - + converted[field] = convertToUTC(converted[field], timezone).toISOString(); } }); @@ -470,7 +507,7 @@ export function createTimezoneAwareApiCall( let debugLogging = false; // Enable/disable debug logging -export function enableTimezoneDebugLogging(enable: boolean = true): void { +export function enableTimezoneDebugLogging(enable = true): void { debugLogging = enable; } diff --git a/test-comparison.js b/test-comparison.js new file mode 100644 index 000000000000..5693046f0766 --- /dev/null +++ b/test-comparison.js @@ -0,0 +1,95 @@ +// Simple test to verify our comparison logic works +function testComparisonLogic() { + console.log('๐Ÿงช Testing BigNumber Comparison Logic'); + + // Test case 1: Positive change + const test1 = { + queriesResponse: [{ + data: [{ 'Gross Sale': 120, 'Gross Sale__1 day ago': 100 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'] + }], + formData: { viz_type: 'big_number_total', metric: 'Gross Sale' } + }; + + // Test case 2: Negative change (from your logs) + const test2 = { + queriesResponse: [{ + data: [{ 'Gross Sale': 42324187.71, 'Gross Sale__1 day ago': 65945361.96 }], + colnames: ['Gross Sale', 'Gross Sale__1 day ago'] + }], + formData: { viz_type: 'big_number_total', metric: 'Gross Sale' } + }; + + // Test case 3: Non-BigNumber chart + const test3 = { + queriesResponse: [{ + data: [{ 'Value': 100, 'Value__1 day ago': 80 }], + colnames: ['Value', 'Value__1 day ago'] + }], + formData: { viz_type: 'table', metric: 'Value' } + }; + + function extractComparisonData(queriesResponse, formData) { + let bigNumberComparisonData = null; + + if (queriesResponse && queriesResponse.length > 0 && formData) { + const { data = [], colnames = [] } = queriesResponse[0]; + const vizType = formData?.viz_type; + + if (data.length > 0 && vizType && vizType.includes('big_number')) { + const hasTimeOffsetColumns = colnames?.some( + (col) => col.includes('__') && col !== formData.metric + ); + + if (hasTimeOffsetColumns) { + const metricName = formData.metric || 'value'; + const currentValue = data[0][metricName]; + + let previousPeriodValue = null; + for (const col of colnames) { + if (col.includes('__') && col !== metricName) { + const rawValue = data[0][col]; + if (rawValue !== null && rawValue !== undefined) { + previousPeriodValue = typeof rawValue === 'number' ? rawValue : parseFloat(rawValue); + break; + } + } + } + + if (currentValue !== null && previousPeriodValue !== null && !isNaN(previousPeriodValue)) { + let percentageChange = 0; + let comparisonIndicator = 'neutral'; + + if (previousPeriodValue === 0) { + percentageChange = currentValue > 0 ? 1 : currentValue < 0 ? -1 : 0; + comparisonIndicator = currentValue > 0 ? 'positive' : currentValue < 0 ? 'negative' : 'neutral'; + } else if (currentValue === 0) { + percentageChange = -1; + comparisonIndicator = 'negative'; + } else { + percentageChange = (currentValue - previousPeriodValue) / Math.abs(previousPeriodValue); + comparisonIndicator = percentageChange > 0 ? 'positive' : percentageChange < 0 ? 'negative' : 'neutral'; + } + + bigNumberComparisonData = { + percentageChange, + comparisonIndicator, + previousPeriodValue, + currentValue, + }; + } + } + } + } + + return bigNumberComparisonData; + } + + console.log('Test 1 (Positive change):', extractComparisonData(test1.queriesResponse, test1.formData)); + console.log('Test 2 (Negative change):', extractComparisonData(test2.queriesResponse, test2.formData)); + console.log('Test 3 (Non-BigNumber):', extractComparisonData(test3.queriesResponse, test3.formData)); + + console.log('โœ… Test completed'); +} + +testComparisonLogic();