From bd7d4145195327bd79eefbc415cb48ff0987a341 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 16 Mar 2026 14:53:25 +0100 Subject: [PATCH 01/25] Create SpendOverTime widget for the Home page --- .../Charts/BarChart/BarChartContent.tsx | 109 ++++++------ src/components/Charts/BarChart/index.tsx | 15 +- .../Charts/LineChart/LineChartContent.tsx | 123 +++++++------- src/components/Charts/LineChart/index.tsx | 18 +- .../Charts/PieChart/PieChartContent.tsx | 14 +- src/components/Charts/types.ts | 10 +- .../Search/FilterDropdowns/DropdownButton.tsx | 4 +- src/components/Search/SearchBarChart.tsx | 5 +- src/components/Search/SearchChartView.tsx | 151 ++--------------- src/components/Search/SearchChartWrapper.tsx | 28 +++ src/components/Search/SearchLineChart.tsx | 7 +- src/components/Search/SearchPieChart.tsx | 4 +- src/components/Search/chartGroupByConfig.ts | 97 +++++++++++ src/components/Search/index.tsx | 29 +++- src/components/Search/types.ts | 9 +- src/components/WidgetContainer.tsx | 6 +- src/libs/SearchUIUtils.ts | 8 +- src/pages/home/HomePage.tsx | 2 + src/pages/home/SpendOverTimeSection.tsx | 160 ++++++++++++++++++ src/styles/index.ts | 11 +- 20 files changed, 485 insertions(+), 325 deletions(-) create mode 100644 src/components/Search/SearchChartWrapper.tsx create mode 100644 src/components/Search/chartGroupByConfig.ts create mode 100644 src/pages/home/SpendOverTimeSection.tsx diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 044ae9850e65..dcc94f6abc9a 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -7,7 +7,6 @@ import {useSharedValue} from 'react-native-reanimated'; import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import ChartHeader from '@components/Charts/components/ChartHeader'; import ChartTooltip from '@components/Charts/components/ChartTooltip'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import { @@ -24,7 +23,6 @@ import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, rotatedLabelYOffset} from '@components/Charts/utils'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -75,10 +73,9 @@ type BarChartProps = CartesianChartProps & { useSingleColor?: boolean; }; -function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { +function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress, shouldKeepConstantHeight}: BarChartProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); const [barAreaWidth, setBarAreaWidth] = useState(0); @@ -232,13 +229,13 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni }; const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); - const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const dynamicChartStyle = {height: shouldKeepConstantHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'BarChartContent', isLoading, isFontLoading: !font}; return ( - + - - - - {chartWidth > 0 && ( - - {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}} - - )} - {isTooltipActive && !!tooltipData && ( - - )} - - - + + + {chartWidth > 0 && ( + + {({points, chartBounds}) => points.y.map((point) => renderBar(point, chartBounds, points.y.length))} + + )} + {isTooltipActive && !!tooltipData && ( + + )} + + ); } diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 4f58d04ce562..4d5631481717 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -2,6 +2,7 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/constants'; import useThemeStyles from '@hooks/useThemeStyles'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {BarChartProps} from './BarChartContent'; @@ -17,7 +18,17 @@ function BarChart(props: BarChartProps) { getComponent={getBarChartContent} componentProps={props} fallback={ - + void; }; -function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { +function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress, shouldKeepConstantHeight}: LineChartProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); const [plotAreaWidth, setPlotAreaWidth] = useState(0); @@ -229,13 +226,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn }; const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); - const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const dynamicChartStyle = {height: shouldKeepConstantHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'LineChartContent', isLoading, isFontLoading: !font}; return ( - + - - - - {chartWidth > 0 && ( - - {({points}) => ( - - )} - - )} - {isTooltipActive && !!tooltipData && ( - - )} - - - + + + {chartWidth > 0 && ( + + {({points}) => ( + + )} + + )} + {isTooltipActive && !!tooltipData && ( + + )} + + ); } diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 15e24efcdbbc..41e80106141e 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -2,10 +2,12 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/constants'; import useThemeStyles from '@hooks/useThemeStyles'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {LineChartProps} from './LineChartContent'; +const getLineChartContent = () => import('./LineChartContent'); function LineChart(props: LineChartProps) { const styles = useThemeStyles(); const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'LineChart.SkiaWebLoading'}; @@ -13,10 +15,20 @@ function LineChart(props: LineChartProps) { return ( `/${file}`}} - getComponent={() => import('./LineChartContent')} + getComponent={getLineChartContent} componentProps={props} fallback={ - + + - - + <> {processedSlices.map((slice) => renderLegendItem(slice))} - + ); } diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index c7654c2c41f8..16892a4a6a2a 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -1,5 +1,4 @@ import type {ValueOf} from 'type-fest'; -import type IconAsset from '@src/types/utils/IconAsset'; import type {LABEL_ROTATIONS} from './constants'; type ChartDataPoint = { @@ -26,12 +25,6 @@ type ChartProps = { /** Data points to display */ data: ChartDataPoint[]; - /** Chart title (e.g., "Top Categories", "Spend over time") */ - title?: string; - - /** Icon displayed next to the title */ - titleIcon?: IconAsset; - /** Whether data is loading */ isLoading?: boolean; }; @@ -42,6 +35,9 @@ type CartesianChartProps = ChartProps & { /** Position of the unit symbol relative to the value. Defaults to 'left'. */ yAxisUnitPosition?: UnitPosition; + + /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. */ + shouldKeepConstantHeight?: boolean; }; type PieSlice = { diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index f0012ea465e7..4aab1c9d9823 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -141,8 +141,8 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi > { const currency = item.currency ?? 'USD'; const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); @@ -32,12 +32,11 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onIte return ( ); } diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 33402bbe5e31..b4bf391bb531 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -1,115 +1,17 @@ import React from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import {View} from 'react-native'; -import Animated from 'react-native-reanimated'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionMemberGroupListItemType, - TransactionMerchantGroupListItemType, - TransactionMonthGroupListItemType, - TransactionQuarterGroupListItemType, - TransactionTagGroupListItemType, - TransactionWeekGroupListItemType, - TransactionWithdrawalIDGroupListItemType, - TransactionYearGroupListItemType, -} from '@components/SelectionListWithSections/types'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import DateUtils from '@libs/DateUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {formatToParts} from '@libs/NumberFormatUtils'; import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import CHART_GROUP_BY_CONFIG from './chartGroupByConfig'; import SearchBarChart from './SearchBarChart'; import SearchLineChart from './SearchLineChart'; import SearchPieChart from './SearchPieChart'; import type {ChartView, GroupedItem, SearchChartProps, SearchGroupBy, SearchQueryJSON} from './types'; -type ChartGroupByConfig = { - titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; - getLabel: (item: GroupedItem) => string; - getFilterQuery: (item: GroupedItem) => string; -}; - -/** - * Chart-specific configuration for each groupBy type - defines how to extract label and build filter query - * for displaying grouped transaction data in charts. - */ -const CHART_GROUP_BY_CONFIG: Record = { - [CONST.SEARCH.GROUP_BY.FROM]: { - titleIconName: 'Users', - getLabel: (item: GroupedItem) => (item as TransactionMemberGroupListItemType).formattedFrom ?? '', - getFilterQuery: (item: GroupedItem) => `from:${(item as TransactionMemberGroupListItemType).accountID}`, - }, - [CONST.SEARCH.GROUP_BY.CARD]: { - titleIconName: 'CreditCard', - getLabel: (item: GroupedItem) => (item as TransactionCardGroupListItemType).formattedCardName ?? '', - getFilterQuery: (item: GroupedItem) => `cardID:${(item as TransactionCardGroupListItemType).cardID}`, - }, - [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: { - titleIconName: 'Send', - // eslint-disable-next-line rulesdir/no-default-id-values -- formattedWithdrawalID is a display label, not an Onyx ID - getLabel: (item: GroupedItem) => (item as TransactionWithdrawalIDGroupListItemType).formattedWithdrawalID ?? '', - getFilterQuery: (item: GroupedItem) => `withdrawalID:${(item as TransactionWithdrawalIDGroupListItemType).entryID}`, - }, - [CONST.SEARCH.GROUP_BY.CATEGORY]: { - titleIconName: 'Folder', - getLabel: (item: GroupedItem) => (item as TransactionCategoryGroupListItemType).formattedCategory ?? '', - getFilterQuery: (item: GroupedItem) => `category:"${(item as TransactionCategoryGroupListItemType).category}"`, - }, - [CONST.SEARCH.GROUP_BY.MERCHANT]: { - titleIconName: 'Basket', - getLabel: (item: GroupedItem) => (item as TransactionMerchantGroupListItemType).formattedMerchant ?? '', - getFilterQuery: (item: GroupedItem) => `merchant:"${(item as TransactionMerchantGroupListItemType).merchant}"`, - }, - [CONST.SEARCH.GROUP_BY.TAG]: { - titleIconName: 'Tag', - getLabel: (item: GroupedItem) => (item as TransactionTagGroupListItemType).formattedTag ?? '', - getFilterQuery: (item: GroupedItem) => `tag:"${(item as TransactionTagGroupListItemType).tag}"`, - }, - [CONST.SEARCH.GROUP_BY.MONTH]: { - titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '', - getFilterQuery: (item: GroupedItem) => { - const monthItem = item as TransactionMonthGroupListItemType; - const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month); - return `date>=${start} date<=${end}`; - }, - }, - [CONST.SEARCH.GROUP_BY.WEEK]: { - titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', - getFilterQuery: (item: GroupedItem) => { - const weekItem = item as TransactionWeekGroupListItemType; - const {start, end} = DateUtils.getWeekDateRange(weekItem.week); - return `date>=${start} date<=${end}`; - }, - }, - [CONST.SEARCH.GROUP_BY.YEAR]: { - titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionYearGroupListItemType).formattedYear ?? '', - getFilterQuery: (item: GroupedItem) => { - const yearItem = item as TransactionYearGroupListItemType; - const {start, end} = DateUtils.getYearDateRange(yearItem.year); - return `date>=${start} date<=${end}`; - }, - }, - [CONST.SEARCH.GROUP_BY.QUARTER]: { - titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '', - getFilterQuery: (item: GroupedItem) => { - const quarterItem = item as TransactionQuarterGroupListItemType; - const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter); - return `date>=${start} date<=${end}`; - }, - }, -}; - type SearchChartViewProps = { /** The current search query JSON */ queryJSON: SearchQueryJSON; @@ -126,14 +28,8 @@ type SearchChartViewProps = { /** Whether data is loading */ isLoading?: boolean; - /** Scroll handler for hiding the top bar on mobile */ - onScroll?: (event: NativeSyntheticEvent) => void; - - /** Layout handler for the root scroll view */ - onLayout?: () => void; - - /** Title to be displayed on the chart */ - title: string; + /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. */ + shouldKeepConstantHeight?: boolean; }; /** @@ -149,14 +45,10 @@ const CHART_VIEW_TO_COMPONENT: Record { @@ -184,30 +76,17 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll, o const unitPosition = currencyIndex < integerIndex ? 'left' : 'right'; return ( - - - - - + ); } -SearchChartView.displayName = 'SearchChartView'; - export default SearchChartView; diff --git a/src/components/Search/SearchChartWrapper.tsx b/src/components/Search/SearchChartWrapper.tsx new file mode 100644 index 000000000000..164f5ebb5eff --- /dev/null +++ b/src/components/Search/SearchChartWrapper.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {View} from 'react-native'; +import {ChartHeader} from '@components/Charts'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CHART_GROUP_BY_CONFIG from './chartGroupByConfig'; +import type {SearchGroupBy} from './types'; + +function SearchChartWrapper({children, title, groupBy}: {children: React.ReactNode; title?: string; groupBy: SearchGroupBy}) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['Users', 'CreditCard', 'Send', 'Folder', 'Basket', 'Tag', 'Calendar']); + const {titleIconName} = CHART_GROUP_BY_CONFIG[groupBy]; + const titleIcon = icons[titleIconName]; + + return ( + + + {children} + + ); +} + +export default SearchChartWrapper; diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index e8a7e4dcc9db..269352f5d4f0 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -4,7 +4,7 @@ import type {ChartDataPoint} from '@components/Charts'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type {SearchChartProps} from './types'; -function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { +function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition, shouldKeepConstantHeight}: SearchChartProps) { const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); @@ -32,16 +32,13 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onIt return ( ); } -SearchLineChart.displayName = 'SearchLineChart'; - export default SearchLineChart; diff --git a/src/components/Search/SearchPieChart.tsx b/src/components/Search/SearchPieChart.tsx index 9eb27743b34d..47f8022a4ae7 100644 --- a/src/components/Search/SearchPieChart.tsx +++ b/src/components/Search/SearchPieChart.tsx @@ -4,7 +4,7 @@ import type {ChartDataPoint} from '@components/Charts/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type {SearchChartProps} from './types'; -function SearchPieChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { +function SearchPieChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { // Transform grouped transaction data to PieChart format const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; @@ -31,8 +31,6 @@ function SearchPieChart({data, title, titleIcon, getLabel, getFilterQuery, onIte return ( string; + getFilterQuery: (item: GroupedItem) => string; +}; + +/** + * Chart-specific configuration for each groupBy type - defines how to extract label and build filter query + * for displaying grouped transaction data in charts. + */ +const CHART_GROUP_BY_CONFIG: Record = { + [CONST.SEARCH.GROUP_BY.FROM]: { + titleIconName: 'Users', + getLabel: (item: GroupedItem) => (item as TransactionMemberGroupListItemType).formattedFrom ?? '', + getFilterQuery: (item: GroupedItem) => `from:${(item as TransactionMemberGroupListItemType).accountID}`, + }, + [CONST.SEARCH.GROUP_BY.CARD]: { + titleIconName: 'CreditCard', + getLabel: (item: GroupedItem) => (item as TransactionCardGroupListItemType).formattedCardName ?? '', + getFilterQuery: (item: GroupedItem) => `cardID:${(item as TransactionCardGroupListItemType).cardID}`, + }, + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: { + titleIconName: 'Send', + // eslint-disable-next-line rulesdir/no-default-id-values -- formattedWithdrawalID is a display label, not an Onyx ID + getLabel: (item: GroupedItem) => (item as TransactionWithdrawalIDGroupListItemType).formattedWithdrawalID ?? '', + getFilterQuery: (item: GroupedItem) => `withdrawalID:${(item as TransactionWithdrawalIDGroupListItemType).entryID}`, + }, + [CONST.SEARCH.GROUP_BY.CATEGORY]: { + titleIconName: 'Folder', + getLabel: (item: GroupedItem) => (item as TransactionCategoryGroupListItemType).formattedCategory ?? '', + getFilterQuery: (item: GroupedItem) => `category:"${(item as TransactionCategoryGroupListItemType).category}"`, + }, + [CONST.SEARCH.GROUP_BY.MERCHANT]: { + titleIconName: 'Basket', + getLabel: (item: GroupedItem) => (item as TransactionMerchantGroupListItemType).formattedMerchant ?? '', + getFilterQuery: (item: GroupedItem) => `merchant:"${(item as TransactionMerchantGroupListItemType).merchant}"`, + }, + [CONST.SEARCH.GROUP_BY.TAG]: { + titleIconName: 'Tag', + getLabel: (item: GroupedItem) => (item as TransactionTagGroupListItemType).formattedTag ?? '', + getFilterQuery: (item: GroupedItem) => `tag:"${(item as TransactionTagGroupListItemType).tag}"`, + }, + [CONST.SEARCH.GROUP_BY.MONTH]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '', + getFilterQuery: (item: GroupedItem) => { + const monthItem = item as TransactionMonthGroupListItemType; + const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month); + return `date>=${start} date<=${end}`; + }, + }, + [CONST.SEARCH.GROUP_BY.WEEK]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', + getFilterQuery: (item: GroupedItem) => { + const weekItem = item as TransactionWeekGroupListItemType; + const {start, end} = DateUtils.getWeekDateRange(weekItem.week); + return `date>=${start} date<=${end}`; + }, + }, + [CONST.SEARCH.GROUP_BY.YEAR]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionYearGroupListItemType).formattedYear ?? '', + getFilterQuery: (item: GroupedItem) => { + const yearItem = item as TransactionYearGroupListItemType; + const {start, end} = DateUtils.getYearDateRange(yearItem.year); + return `date>=${start} date<=${end}`; + }, + }, + [CONST.SEARCH.GROUP_BY.QUARTER]: { + titleIconName: 'Calendar', + getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '', + getFilterQuery: (item: GroupedItem) => { + const quarterItem = item as TransactionQuarterGroupListItemType; + const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter); + return `date>=${start} date<=${end}`; + }, + }, +}; + +export default CHART_GROUP_BY_CONFIG; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e3e062a0c98a..15a0475a259f 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -81,6 +81,7 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; import SearchChartView from './SearchChartView'; +import SearchChartWrapper from './SearchChartWrapper'; import {useSearchActionsContext, useSearchStateContext} from './SearchContext'; import SearchList from './SearchList'; import {SearchScopeProvider} from './SearchScopeProvider'; @@ -1391,16 +1392,28 @@ function Search({ return ( - + scrollEventThrottle={CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE} + > + + + + + + ); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 89922dd82793..b9200af029b2 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -348,12 +348,6 @@ type SearchChartProps = { /** Grouped transaction data from search results */ data: GroupedItem[]; - /** Chart title */ - title: string; - - /** Chart title icon */ - titleIcon: IconAsset; - /** Function to extract label from grouped item */ getLabel: (item: GroupedItem) => string; @@ -371,6 +365,9 @@ type SearchChartProps = { /** Position of currency symbol relative to value */ unitPosition?: UnitPosition; + + /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. */ + shouldKeepConstantHeight?: boolean; }; export type { diff --git a/src/components/WidgetContainer.tsx b/src/components/WidgetContainer.tsx index d1962a54c3f0..fa94b23edd48 100644 --- a/src/components/WidgetContainer.tsx +++ b/src/components/WidgetContainer.tsx @@ -16,9 +16,12 @@ type WidgetContainerProps = { /** Additional styles to pass to the container */ containerStyles?: StyleProp; + + /** The content to display on the right side of the title */ + titleRightContent?: ReactNode; }; -function WidgetContainer({children, title, containerStyles}: WidgetContainerProps) { +function WidgetContainer({children, title, containerStyles, titleRightContent}: WidgetContainerProps) { const styles = useThemeStyles(); const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -27,6 +30,7 @@ function WidgetContainer({children, title, containerStyles}: WidgetContainerProp {!!title && {title}} + {titleRightContent} {children} diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 31907d214b8e..3a0d092935fd 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -952,6 +952,10 @@ function isEligibleForApproveSuggestion(approvalMode: string | undefined, isAppr return isApprovalEnabled && (isApprover || isSubmittedToTarget); } +function isPolicyEligibleForSpendOverTime(policy: OnyxTypes.Policy, currentUserEmail: string | undefined): boolean { + return isPaidGroupPolicy(policy) && (policy.role === CONST.POLICY.ROLE.ADMIN || policy.role === CONST.POLICY.ROLE.AUDITOR || policy.approver === currentUserEmail); +} + function getSuggestedSearchesVisibility( currentUserEmail: string | undefined, cardFeedsByPolicy: Record, @@ -1021,7 +1025,6 @@ function getSuggestedSearchesVisibility( const isEligibleForTopSpendersSuggestion = isPaidPolicy && (isAdmin || isAuditor || isUserApprover) && memberCount >= 2; const isEligibleForTopCategoriesSuggestion = isPaidPolicy && policy.areCategoriesEnabled === true; const isEligibleForTopMerchantsSuggestion = isPaidPolicy; - const isEligibleForSpendOverTimeSuggestion = isPaidPolicy && (isAdmin || isAuditor || isUserApprover); shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -1041,7 +1044,7 @@ function getSuggestedSearchesVisibility( !policy.isJoinRequestPending && (policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) && !!policy.role; - shouldShowSpendOverTimeSuggestion ||= isEligibleForSpendOverTimeSuggestion; + shouldShowSpendOverTimeSuggestion ||= isPolicyEligibleForSpendOverTime(policy, currentUserEmail); // We don't need to check the rest of the policies if we already determined that all suggestions should be displayed return ( @@ -4783,5 +4786,6 @@ export { adjustTimeRangeToDateFilters, isEligibleForApproveSuggestion, applySelectionToItem, + isPolicyEligibleForSpendOverTime, }; export type {SavedSearchMenuItem, SearchTypeMenuSection, SearchTypeMenuItem, SearchDateModifier, SearchDateModifierLower, SearchKey, ArchivedReportsIDSet}; diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 498f242aab11..15d06aa7cf88 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -19,6 +19,7 @@ import AnnouncementSection from './AnnouncementSection'; import AssignedCardsSection from './AssignedCardsSection'; import DiscoverSection from './DiscoverSection'; import ForYouSection from './ForYouSection'; +import SpendOverTimeSection from './SpendOverTimeSection'; import TimeSensitiveSection from './TimeSensitiveSection'; import UpcomingTravelSection from './UpcomingTravelSection'; @@ -74,6 +75,7 @@ function HomePage() { + diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx new file mode 100644 index 000000000000..24fd000e6cdf --- /dev/null +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -0,0 +1,160 @@ +import React, {useEffect, useLayoutEffect, useRef} from 'react'; +import {View} from 'react-native'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import Button from '@components/Button'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/constants'; +import SearchChartView from '@components/Search/SearchChartView'; +import type {GroupedItem} from '@components/Search/types'; +import WidgetContainer from '@components/WidgetContainer'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {search} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +import {getSections, getSortedSections, getSuggestedSearches, isPolicyEligibleForSpendOverTime, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +const spendOverTimeSearchConfig = getSuggestedSearches()[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; +const query = spendOverTimeSearchConfig.searchQuery; +const queryJSON = spendOverTimeSearchConfig.searchQueryJSON; +const groupBy = queryJSON?.groupBy; +const view = queryJSON?.view; +const searchKey = spendOverTimeSearchConfig.key; + +function SpendOverTimeSection() { + const styles = useThemeStyles(); + const {translate, localeCompare, formatPhoneNumber} = useLocalize(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['Expand', 'OfflineCloud']); + const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const {accountID, login} = useCurrentUserPersonalDetails(); + const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON?.hash}`); + const {isOffline} = useNetwork(); + const isVisible = Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)); + + // We need the snapshot's isLoading in the search effect without subscribing to it (which would cause an infinite loop). + // useLayoutEffect syncs the ref before useEffect runs. TODO: Replace with useEffectEvent after upgrading to React 19.2. + const isSearchLoadingRef = useRef(false); + + useLayoutEffect(() => { + isSearchLoadingRef.current = !!searchResults?.search?.isLoading; + }, [searchResults?.search?.isLoading]); + + useEffect(() => { + if (!isVisible || isOffline || !queryJSON || isSearchLoadingRef.current) { + return; + } + search({ + queryJSON, + searchKey, + offset: 0, + isOffline: false, + isLoading: false, + }); + }, [isVisible, isOffline]); + + if (!isVisible || !queryJSON || !view || !groupBy || view === CONST.SEARCH.VIEW.TABLE || !login) { + return null; + } + + const sortedData = searchResults?.data + ? (getSortedSections( + queryJSON.type, + queryJSON.status, + getSections({ + type: queryJSON.type, + data: searchResults.data, + groupBy, + queryJSON, + currentAccountID: accountID, + currentUserEmail: login, + translate, + formatPhoneNumber, + bankAccountList: undefined, + allReportMetadata: undefined, + })[0], + localeCompare, + translate, + queryJSON.sortBy, + queryJSON.sortOrder, + groupBy, + ) as GroupedItem[]) + : undefined; + + const shouldShowOfflineIndicator = isOffline && !sortedData; + const shouldShowErrorIndicator = !shouldShowOfflineIndicator && Object.keys(searchResults?.errors ?? {}).length > 0; + const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, queryJSON); + + if (!shouldShowErrorIndicator && sortedData?.length === 0) { + return null; + } + + return ( + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query}))} + iconRight={icons.Expand} + shouldShowRightIcon + textStyles={styles.pb0} + /> + ) + } + > + {shouldShowOfflineIndicator && ( + + )} + {shouldShowErrorIndicator && ( + + )} + {!shouldShowOfflineIndicator && !shouldShowErrorIndicator && ( + + + + )} + + ); +} + +export default SpendOverTimeSection; diff --git a/src/styles/index.ts b/src/styles/index.ts index ed4e012c6510..8fa9828a24d7 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5822,24 +5822,15 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.transparent, borderStyle: 'solid', }, - barChartContainer: { + chartContainer: { borderRadius: variables.componentBorderRadiusLarge, }, barChartChartContainer: { minHeight: 250, }, - lineChartContainer: { - borderRadius: variables.componentBorderRadiusLarge, - paddingTop: variables.qrShareHorizontalPadding, - paddingHorizontal: variables.qrShareHorizontalPadding, - }, lineChartChartContainer: { minHeight: 250, }, - pieChartContainer: { - borderRadius: variables.componentBorderRadiusLarge, - padding: variables.qrShareHorizontalPadding, - }, pieChartChartContainer: { height: 250, position: 'relative', From 696da7e2297711c7d84dbb8f7cd92590018d0113 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 16 Mar 2026 15:11:35 +0100 Subject: [PATCH 02/25] Small fix --- src/components/Charts/PieChart/PieChartContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index f120dd9e36fe..51c6ab7fc8e7 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -142,7 +142,7 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice if (isLoading) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'PieChartContent', isLoading}; return ( - + Date: Mon, 16 Mar 2026 15:55:24 +0100 Subject: [PATCH 03/25] Fix checking if user is approver for insight visibility --- src/libs/PolicyUtils.ts | 11 +++++++++ src/libs/ReportPrimaryActionUtils.ts | 6 ++--- src/libs/ReportSecondaryActionUtils.ts | 6 ++--- src/libs/SearchUIUtils.ts | 23 +++++++------------ src/libs/actions/Policy/Member.ts | 11 --------- src/pages/workspace/WorkspaceMembersPage.tsx | 8 +++---- src/pages/workspace/WorkspaceOverviewPage.tsx | 5 ++-- src/pages/workspace/WorkspacesListPage.tsx | 5 ++-- .../members/WorkspaceMemberDetailsPage.tsx | 8 +++---- 9 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 7135263a295c..e2126652abf4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -459,6 +459,16 @@ function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | und return false; } +/** Check if the passed employee is an approver in the policy's employeeList */ +function isPolicyApprover(policy: OnyxEntry, employeeLogin: string) { + if (policy?.approver === employeeLogin) { + return true; + } + return Object.values(policy?.employeeList ?? {}).some( + (employee) => employee?.submitsTo === employeeLogin || employee?.forwardsTo === employeeLogin || employee?.overLimitForwardsTo === employeeLogin, + ); +} + function getUberConnectionErrorDirectlyFromPolicy(policy: OnyxEntry) { const receiptUber = policy?.receiptPartners?.uber; @@ -2221,6 +2231,7 @@ export { getActivePoliciesWithExpenseChatAndTimeEnabled, isPolicyTaxEnabled, sortPoliciesByName, + isPolicyApprover, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 698b23723a1a..f34b04661e72 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -3,7 +3,6 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BankAccountList, Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; -import {isApprover as isApproverUtils} from './actions/Policy/Member'; import { arePaymentsEnabled as arePaymentsEnabledUtils, getSubmitToAccountID, @@ -12,6 +11,7 @@ import { hasIntegrationAutoSync, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, + isPolicyApprover, isPreferredExporter, } from './PolicyUtils'; import { @@ -352,7 +352,7 @@ function isMarkAsCashAction( } const isReportSubmitter = isCurrentUserSubmitter(report); - const isReportApprover = isApproverUtils(policy, currentUserEmail); + const isReportApprover = isPolicyApprover(policy, currentUserEmail); const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; const shouldShowBrokenConnectionViolation = shouldShowBrokenConnectionViolationForMultipleTransactions( @@ -509,7 +509,7 @@ function isMarkAsCashActionForTransaction(currentUserLogin: string, parentReport } const isReportSubmitter = isCurrentUserSubmitter(parentReport); - const isReportApprover = isApproverUtils(policy, currentUserLogin); + const isReportApprover = isPolicyApprover(policy, currentUserLogin); const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; return isReportSubmitter || isReportApprover || isAdmin; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 2cd9c0a30d39..c4f6937d1216 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -3,7 +3,6 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BankAccountList, ExportTemplate, Policy, Report, ReportAction, ReportMetadata, ReportNameValuePairs, Transaction, TransactionViolation} from '@src/types/onyx'; -import {isApprover as isApproverUtils} from './actions/Policy/Member'; import {areTransactionsEligibleForMerge} from './MergeTransactionUtils'; import {getLoginByAccountID} from './PersonalDetailsUtils'; import { @@ -17,6 +16,7 @@ import { isInstantSubmitEnabled, isPaidGroupPolicy, isPolicyAdmin, + isPolicyApprover, isPolicyMember, isPreferredExporter, isSubmitAndClose, @@ -334,14 +334,14 @@ function isApproveAction( currentUserLogin, currentUserAccountID, ); - const isReportApprover = isApproverUtils(policy, currentUserLogin); + const isReportApprover = isPolicyApprover(policy, currentUserLogin); const userControlsReport = isReportApprover || isAdmin; return userControlsReport && shouldShowBrokenConnectionViolation; } function isUnapproveAction(currentUserLogin: string, currentUserAccountID: number, report: Report, policy?: Policy): boolean { const isExpenseReport = isExpenseReportUtils(report); - const isReportApprover = isApproverUtils(policy, currentUserLogin); + const isReportApprover = isPolicyApprover(policy, currentUserLogin); const isReportApproved = isReportApprovedUtils({report}); const isReportSettled = isSettled(report); const isPaymentProcessing = report.isWaitingOnBankAccount && report.statusNum === CONST.REPORT.STATUS_NUM.APPROVED; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 3a0d092935fd..fcd50e947bb8 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -99,7 +99,7 @@ import isSearchTopmostFullScreenRoute from './Navigation/helpers/isSearchTopmost import Navigation from './Navigation/Navigation'; import Parser from './Parser'; import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; -import {arePaymentsEnabled, canSendInvoice, getCommaSeparatedTagNameWithSanitizedColons, getSubmitToAccountID, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; +import {arePaymentsEnabled, canSendInvoice, getCommaSeparatedTagNameWithSanitizedColons, getSubmitToAccountID, isPaidGroupPolicy, isPolicyApprover, isPolicyPayer} from './PolicyUtils'; import { getIOUActionForReportID, getOriginalMessage, @@ -953,7 +953,10 @@ function isEligibleForApproveSuggestion(approvalMode: string | undefined, isAppr } function isPolicyEligibleForSpendOverTime(policy: OnyxTypes.Policy, currentUserEmail: string | undefined): boolean { - return isPaidGroupPolicy(policy) && (policy.role === CONST.POLICY.ROLE.ADMIN || policy.role === CONST.POLICY.ROLE.AUDITOR || policy.approver === currentUserEmail); + return ( + isPaidGroupPolicy(policy) && + (policy.role === CONST.POLICY.ROLE.ADMIN || policy.role === CONST.POLICY.ROLE.AUDITOR || (!!currentUserEmail && isPolicyApprover(policy, currentUserEmail))) + ); } function getSuggestedSearchesVisibility( @@ -988,19 +991,9 @@ function getSuggestedSearchesVisibility( const isPayer = isPolicyPayer(policy, currentUserEmail); const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; const isExporter = policy.exporter === currentUserEmail; - let isSubmittedTo = false; - let isUserApprover = policy.approver === currentUserEmail; - for (const employee of Object.values(policy.employeeList ?? {})) { - if (employee?.submitsTo === currentUserEmail || employee?.forwardsTo === currentUserEmail) { - isSubmittedTo = true; - isUserApprover = true; - } else if (employee?.overLimitForwardsTo === currentUserEmail) { - isUserApprover = true; - } - if (isSubmittedTo && isUserApprover) { - break; - } - } + + const isSubmittedTo = Object.values(policy.employeeList ?? {}).some((employee) => employee.submitsTo === currentUserEmail || employee.forwardsTo === currentUserEmail); + const isUserApprover = !!currentUserEmail && isPolicyApprover(policy, currentUserEmail); const isApprovalEnabled = policy.approvalMode ? policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; const hasExportError = (Object.keys(policy.connections ?? {}) as ConnectionName[]).some((connection) => { diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 56e52fb8e334..2f39e131fe68 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -58,16 +58,6 @@ Onyx.connectWithoutView({ }, }); -/** Check if the passed employee is an approver in the policy's employeeList */ -function isApprover(policy: OnyxEntry, employeeLogin: string) { - if (policy?.approver === employeeLogin) { - return true; - } - return Object.values(policy?.employeeList ?? {}).some( - (employee) => employee?.submitsTo === employeeLogin || employee?.forwardsTo === employeeLogin || employee?.overLimitForwardsTo === employeeLogin, - ); -} - /** * Build optimistic data for adding members to the announcement/admins room */ @@ -1363,7 +1353,6 @@ export { askToJoinPolicy, acceptJoinRequest, declineJoinRequest, - isApprover, importPolicyMembers, downloadMembersCSV, clearInviteDraft, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 50cb63ff0abb..46dc514bd5a4 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -38,7 +38,6 @@ import { clearInviteDraft, clearWorkspaceOwnerChangeFlow, downloadMembersCSV, - isApprover, openWorkspaceMembersPage, removeMembers, updateWorkspaceMembersRole, @@ -60,6 +59,7 @@ import { isExpensifyTeam, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminUtils, + isPolicyApprover, } from '@libs/PolicyUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -163,7 +163,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const canSelectMultiple = isPolicyAdmin && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : true); const confirmModalPrompt = useMemo(() => { - const approverEmail = selectedEmployees.find((selectedEmployee) => isApprover(policy, selectedEmployee)); + const approverEmail = selectedEmployees.find((selectedEmployee) => isPolicyApprover(policy, selectedEmployee)); if (approverEmail) { const approverAccountID = policyMemberEmailsToAccountIDs[approverEmail]; @@ -229,12 +229,12 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers */ const removeUsers = () => { // Check if any of the members are approvers - const hasApprovers = selectedEmployees.some((email) => isApprover(policy, email)); + const hasApprovers = selectedEmployees.some((email) => isPolicyApprover(policy, email)); if (hasApprovers) { const ownerEmail = ownerDetails.login; for (const login of selectedEmployees) { - if (!isApprover(policy, login)) { + if (!isPolicyApprover(policy, login)) { continue; } diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 166e563d5171..a77459a33486 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -32,7 +32,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {close} from '@libs/actions/Modal'; -import {clearInviteDraft, clearWorkspaceOwnerChangeFlow, isApprover as isApproverUserAction, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; +import {clearInviteDraft, clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; import { calculateBillNewDot, clearAvatarErrors, @@ -56,6 +56,7 @@ import { goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, + isPolicyApprover, isPolicyAuditor, isPolicyOwner, shouldBlockWorkspaceDeletionForInvoicifyUser, @@ -368,7 +369,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const technicalContact = policy?.technicalContact; const isCurrentUserReimburser = policy?.achAccount?.reimburser === session?.email; const userEmail = session?.email ?? ''; - const isApprover = isApproverUserAction(policy, userEmail); + const isApprover = isPolicyApprover(policy, userEmail); if (isCurrentUserReimburser) { return translate('common.leaveWorkspaceReimburser'); diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 5d3a86716c72..1ee32e1ed4f6 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -42,7 +42,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; import {isConnectionInProgress} from '@libs/actions/connections'; import {close} from '@libs/actions/Modal'; -import {clearWorkspaceOwnerChangeFlow, isApprover as isApproverUserAction, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; +import {clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; import {calculateBillNewDot, clearDeleteWorkspaceError, clearDuplicateWorkspace, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; @@ -58,6 +58,7 @@ import { getUberConnectionErrorDirectlyFromPolicy, isPendingDeletePolicy, isPolicyAdmin, + isPolicyApprover, isPolicyAuditor, shouldBlockWorkspaceDeletionForInvoicifyUser, shouldShowEmployeeListError, @@ -274,7 +275,7 @@ function WorkspacesListPage() { const technicalContact = policyToLeave?.technicalContact; const isCurrentUserReimburser = isUserReimburserForPolicy(policies, policyIDToLeave, session?.email); const userEmail = session?.email ?? ''; - const isApprover = isApproverUserAction(policyToLeave, userEmail); + const isApprover = isPolicyApprover(policyToLeave, userEmail); if (isCurrentUserReimburser) { return translate('common.leaveWorkspaceReimburser'); diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 2453e6a2ad55..6e13362bed2e 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -44,7 +44,7 @@ import {convertToDisplayString} from '@libs/CurrencyUtils'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDisplayNameOrDefault, getPhoneNumber} from '@libs/PersonalDetailsUtils'; -import {isControlPolicy} from '@libs/PolicyUtils'; +import {isControlPolicy, isPolicyApprover} from '@libs/PolicyUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import Navigation from '@navigation/Navigation'; @@ -54,7 +54,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import variables from '@styles/variables'; -import {clearWorkspaceOwnerChangeFlow, isApprover as isApproverUserAction, openPolicyMemberProfilePage, removeMembers} from '@userActions/Policy/Member'; +import {clearWorkspaceOwnerChangeFlow, openPolicyMemberProfilePage, removeMembers} from '@userActions/Policy/Member'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -127,7 +127,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const memberCards = workspaceCards ? Object.values(workspaceCards).filter((card) => card.accountID === accountID) : []; - const isApprover = isApproverUserAction(policy, memberLogin); + const isApprover = isPolicyApprover(policy, memberLogin); const isTechnicalContact = policy?.technicalContact === details?.login; const exporters = [ policy?.connections?.intacct?.config?.export?.exporter, @@ -186,7 +186,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const removedApprover = personalDetails?.[accountID]; // If the user is not an approver, proceed with member removal - if (!isApproverUserAction(policy, memberLogin) || !removedApprover?.login || !ownerEmail) { + if (!isPolicyApprover(policy, memberLogin) || !removedApprover?.login || !ownerEmail) { removeMemberAndCloseModal(); return; } From d25d232012ee94bda4514c896380775d5c02d6cf Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 16 Mar 2026 16:24:40 +0100 Subject: [PATCH 04/25] Fix test --- tests/unit/ReportSecondaryActionUtilsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index fc15c9ae9c7c..f9fbc77642f8 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -44,6 +44,8 @@ jest.mock('@libs/PolicyUtils', () => ({ isPreferredExporter: jest.fn().mockReturnValue(true), hasAccountingConnections: jest.fn().mockReturnValue(true), isPolicyAdmin: jest.fn().mockReturnValue(true), + isPolicyApprover: (...args: Parameters) => jest.requireActual('@libs/PolicyUtils').isPolicyApprover(...args), + isPolicyAuditor: (...args: Parameters) => jest.requireActual('@libs/PolicyUtils').isPolicyAuditor(...args), getValidConnectedIntegration: jest.fn().mockReturnValue('netsuite'), isPaidGroupPolicy: jest.fn().mockReturnValue(true), })); From 2bc78a4b97dfa375cfd9816b5e29b8a5f7efd6d1 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Mon, 16 Mar 2026 17:03:57 +0100 Subject: [PATCH 05/25] Implement review suggestions --- .../Charts/BarChart/BarChartContent.tsx | 8 +-- src/components/Charts/BarChart/index.tsx | 12 +---- .../Charts/LineChart/LineChartContent.tsx | 8 +-- src/components/Charts/LineChart/index.tsx | 12 +---- .../Charts/PieChart/PieChartContent.tsx | 2 +- src/components/Charts/PieChart/index.tsx | 2 +- src/components/Charts/types.ts | 2 +- src/components/Search/SearchBarChart.tsx | 4 +- src/components/Search/SearchChartView.tsx | 6 +-- src/components/Search/SearchLineChart.tsx | 4 +- src/components/Search/types.ts | 2 +- src/pages/home/SpendOverTimeSection.tsx | 52 +++++++++---------- src/styles/index.ts | 13 +++-- 13 files changed, 56 insertions(+), 71 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index dcc94f6abc9a..8d9350c93e2b 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -73,7 +73,7 @@ type BarChartProps = CartesianChartProps & { useSingleColor?: boolean; }; -function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress, shouldKeepConstantHeight}: BarChartProps) { +function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress, disableDynamicHeight}: BarChartProps) { const theme = useTheme(); const styles = useThemeStyles(); const font = useFont(fontSource, variables.iconSizeExtraSmall); @@ -229,13 +229,13 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left' }; const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); - const dynamicChartStyle = {height: shouldKeepConstantHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const dynamicChartStyle = {height: disableDynamicHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'BarChartContent', isLoading, isFontLoading: !font}; return ( - + {chartWidth > 0 && ( diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 4d5631481717..ff684f96f857 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -18,17 +18,7 @@ function BarChart(props: BarChartProps) { getComponent={getBarChartContent} componentProps={props} fallback={ - + void; }; -function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress, shouldKeepConstantHeight}: LineChartProps) { +function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress, disableDynamicHeight}: LineChartProps) { const theme = useTheme(); const styles = useThemeStyles(); const font = useFont(fontSource, variables.iconSizeExtraSmall); @@ -226,13 +226,13 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left }; const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); - const dynamicChartStyle = {height: shouldKeepConstantHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const dynamicChartStyle = {height: disableDynamicHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'LineChartContent', isLoading, isFontLoading: !font}; return ( - + { const currency = item.currency ?? 'USD'; const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); @@ -36,7 +36,7 @@ function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading, onBarPress={handleBarPress} yAxisUnit={unit} yAxisUnitPosition={unitPosition} - shouldKeepConstantHeight={shouldKeepConstantHeight} + disableDynamicHeight={disableDynamicHeight} /> ); } diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index b4bf391bb531..04ccc53b3141 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -29,7 +29,7 @@ type SearchChartViewProps = { isLoading?: boolean; /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. */ - shouldKeepConstantHeight?: boolean; + disableDynamicHeight?: boolean; }; /** @@ -45,7 +45,7 @@ const CHART_VIEW_TO_COMPONENT: Record ); } diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index 269352f5d4f0..a5f5a9f5a0a5 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -4,7 +4,7 @@ import type {ChartDataPoint} from '@components/Charts'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type {SearchChartProps} from './types'; -function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition, shouldKeepConstantHeight}: SearchChartProps) { +function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition, disableDynamicHeight}: SearchChartProps) { const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); @@ -36,7 +36,7 @@ function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading onPointPress={handlePointPress} yAxisUnit={unit} yAxisUnitPosition={unitPosition} - shouldKeepConstantHeight={shouldKeepConstantHeight} + disableDynamicHeight={disableDynamicHeight} /> ); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index b9200af029b2..fda12abd4c21 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -367,7 +367,7 @@ type SearchChartProps = { unitPosition?: UnitPosition; /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. */ - shouldKeepConstantHeight?: boolean; + disableDynamicHeight?: boolean; }; export type { diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx index 24fd000e6cdf..75f013fa4b83 100644 --- a/src/pages/home/SpendOverTimeSection.tsx +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -22,12 +22,12 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -const spendOverTimeSearchConfig = getSuggestedSearches()[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; -const query = spendOverTimeSearchConfig.searchQuery; -const queryJSON = spendOverTimeSearchConfig.searchQueryJSON; -const groupBy = queryJSON?.groupBy; -const view = queryJSON?.view; -const searchKey = spendOverTimeSearchConfig.key; +const CONFIG = getSuggestedSearches()[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; +const QUERY = CONFIG.searchQuery; +const QUERY_JSON = CONFIG.searchQueryJSON; +const GROUP_BY = QUERY_JSON?.groupBy; +const VIEW = QUERY_JSON?.view; +const SEARCH_KEY = CONFIG.key; function SpendOverTimeSection() { const styles = useThemeStyles(); @@ -39,7 +39,7 @@ function SpendOverTimeSection() { const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {accountID, login} = useCurrentUserPersonalDetails(); - const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON?.hash}`); + const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${QUERY_JSON?.hash}`); const {isOffline} = useNetwork(); const isVisible = Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)); @@ -52,31 +52,31 @@ function SpendOverTimeSection() { }, [searchResults?.search?.isLoading]); useEffect(() => { - if (!isVisible || isOffline || !queryJSON || isSearchLoadingRef.current) { + if (!isVisible || isOffline || !QUERY_JSON || isSearchLoadingRef.current) { return; } search({ - queryJSON, - searchKey, + queryJSON: QUERY_JSON, + searchKey: SEARCH_KEY, offset: 0, isOffline: false, isLoading: false, }); }, [isVisible, isOffline]); - if (!isVisible || !queryJSON || !view || !groupBy || view === CONST.SEARCH.VIEW.TABLE || !login) { + if (!isVisible || !QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE || !login) { return null; } const sortedData = searchResults?.data ? (getSortedSections( - queryJSON.type, - queryJSON.status, + QUERY_JSON.type, + QUERY_JSON.status, getSections({ - type: queryJSON.type, + type: QUERY_JSON.type, data: searchResults.data, - groupBy, - queryJSON, + groupBy: GROUP_BY, + queryJSON: QUERY_JSON, currentAccountID: accountID, currentUserEmail: login, translate, @@ -86,15 +86,15 @@ function SpendOverTimeSection() { })[0], localeCompare, translate, - queryJSON.sortBy, - queryJSON.sortOrder, - groupBy, + QUERY_JSON.sortBy, + QUERY_JSON.sortOrder, + GROUP_BY, ) as GroupedItem[]) : undefined; const shouldShowOfflineIndicator = isOffline && !sortedData; const shouldShowErrorIndicator = !shouldShowOfflineIndicator && Object.keys(searchResults?.errors ?? {}).length > 0; - const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, queryJSON); + const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, QUERY_JSON); if (!shouldShowErrorIndicator && sortedData?.length === 0) { return null; @@ -102,13 +102,13 @@ function SpendOverTimeSection() { return ( Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query}))} + onPress={() => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: QUERY}))} iconRight={icons.Expand} shouldShowRightIcon textStyles={styles.pb0} @@ -144,12 +144,12 @@ function SpendOverTimeSection() { {!shouldShowOfflineIndicator && !shouldShowErrorIndicator && ( )} diff --git a/src/styles/index.ts b/src/styles/index.ts index 8fa9828a24d7..5aec28c96bbe 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5789,6 +5789,14 @@ const staticStyles = (theme: ThemeColors) => transactionReceiptButton: { width: variables.transactionReceiptButtonWidth, }, + chartWebFallback: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.highlightBG, + borderRadius: variables.componentBorderRadiusLarge, + padding: 20, + }, chartHeader: { flexDirection: 'row', alignItems: 'center', @@ -5825,10 +5833,7 @@ const staticStyles = (theme: ThemeColors) => chartContainer: { borderRadius: variables.componentBorderRadiusLarge, }, - barChartChartContainer: { - minHeight: 250, - }, - lineChartChartContainer: { + chartContent: { minHeight: 250, }, pieChartChartContainer: { From f764d7f6cc906b70b1b5b6719f168cc6e9be912a Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 17 Mar 2026 10:51:54 +0100 Subject: [PATCH 06/25] Update disableDynamicHeight comment --- src/components/Search/SearchChartView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 04ccc53b3141..e2b35d5e43fe 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -28,7 +28,7 @@ type SearchChartViewProps = { /** Whether data is loading */ isLoading?: boolean; - /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. */ + /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. (line and bar charts only) */ disableDynamicHeight?: boolean; }; From c53d5a65bb25eaf7847e8140cf6669a6444f58c7 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 17 Mar 2026 10:55:45 +0100 Subject: [PATCH 07/25] Remove unused pieChartChartContainer style --- src/styles/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 5aec28c96bbe..9b4046180aab 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5836,10 +5836,6 @@ const staticStyles = (theme: ThemeColors) => chartContent: { minHeight: 250, }, - pieChartChartContainer: { - height: 250, - position: 'relative', - }, pieChartLegendContainer: { display: 'flex', justifyContent: 'center', From 8a87c831ad81f1c0ba820e8033bc414f4258c4cb Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 17 Mar 2026 15:12:23 +0100 Subject: [PATCH 08/25] Fix View button's width --- src/pages/home/SpendOverTimeSection.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx index 75f013fa4b83..09ce9223ea91 100644 --- a/src/pages/home/SpendOverTimeSection.tsx +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -112,6 +112,8 @@ function SpendOverTimeSection() { iconRight={icons.Expand} shouldShowRightIcon textStyles={styles.pb0} + style={styles.widgetItemButton} + isContentCentered /> ) } From 8cda86e44b2560444f6c51b683635bf0b33136b4 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 17 Mar 2026 15:42:27 +0100 Subject: [PATCH 09/25] Change cursor to pointer when hovering over clickable elements on charts --- .../Charts/BarChart/BarChartContent.tsx | 4 +- .../Charts/LineChart/LineChartContent.tsx | 4 +- .../Charts/PieChart/PieChartContent.tsx | 5 ++- .../Charts/hooks/useChartInteractions.ts | 42 ++++++++++++++----- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 8d9350c93e2b..aa4c11d4daa6 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -172,7 +172,7 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left' return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; }; - const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, isOverClickableTarget, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, checkIsOver: checkIsOverBar, isCursorOverLabel, @@ -251,7 +251,7 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left' return ( {chartWidth > 0 && ( diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index f5b3cfa2c270..b506bcbab5d7 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -178,7 +178,7 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; }; - const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, isOverClickableTarget, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, checkIsOver: checkIsOverDot, isCursorOverLabel, @@ -248,7 +248,7 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left return ( {chartWidth > 0 && ( diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 253193cf8484..4fef28504e89 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -31,6 +31,7 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice const [canvasWidth, setCanvasWidth] = useState(0); const [canvasHeight, setCanvasHeight] = useState(0); const [activeSliceIndex, setActiveSliceIndex] = useState(-1); + const [isHoveringOverPie, setIsHoveringOverPie] = useState(false); // Shared values for hover state const isHovering = useSharedValue(false); @@ -60,6 +61,7 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice const {radius, centerX, centerY} = pieGeometry; const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, 0, processedSlices); setActiveSliceIndex(sliceIndex); + setIsHoveringOverPie(sliceIndex >= 0); }; // Handle slice press callback @@ -102,6 +104,7 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice isHovering.set(false); scheduleOnRN(setActiveSliceIndex, -1); + scheduleOnRN(setIsHoveringOverPie, false); }); // Tap gesture for click/tap navigation @@ -159,7 +162,7 @@ function PieChartContent({data, isLoading, valueUnit, valueUnitPosition, onSlice <> {processedSlices.length > 0 && ( diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 6bcaabffb525..b96e18034538 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -117,6 +117,9 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso /** React state indicating if the cursor is currently "hitting" a target based on checkIsOver */ const [isOverTarget, setIsOverTarget] = useState(false); + /** React state indicating if the cursor is over a clickable element (dot/bar, not label) */ + const [isOverClickableTarget, setIsOverClickableTarget] = useState(false); + /** * Canvas-space x positions for each data point, set by the chart content via setPointPositions. * These replace Victory's internal tData.ox array, enabling worklet-safe nearest-point lookup. @@ -140,27 +143,34 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso [pointOX, pointOY], ); + /** + * Derived value that checks only whether the cursor is over a clickable element + * (e.g. dot, bar) — excludes labels which show tooltip but aren't clickable. + */ + const isCursorOverClickable = useDerivedValue(() => { + const cursorX = chartInteractionState.cursor.x.get(); + const cursorY = chartInteractionState.cursor.y.get(); + const targetX = chartInteractionState.x.position.get(); + const targetY = chartInteractionState.y.y.position.get(); + const currentChartBottom = chartBottom?.get() ?? 0; + return checkIsOver({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}); + }); + /** * Derived value performing the hit-test on the UI thread. * Runs whenever cursor position or matched data points change. + * Includes both clickable targets and labels (for tooltip display). */ const isCursorOverTarget = useDerivedValue(() => { + if (isCursorOverClickable.get()) { + return true; + } const cursorX = chartInteractionState.cursor.x.get(); const cursorY = chartInteractionState.cursor.y.get(); const targetX = chartInteractionState.x.position.get(); const targetY = chartInteractionState.y.y.position.get(); - const currentChartBottom = chartBottom?.get() ?? 0; - return ( - checkIsOver({ - cursorX, - cursorY, - targetX, - targetY, - chartBottom: currentChartBottom, - }) || - (isCursorOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false) - ); + return isCursorOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false; }); /** Syncs the matched data index from the UI thread to React state */ @@ -179,6 +189,14 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso }, ); + /** Syncs the clickable hit-test result from the UI thread to React state */ + useAnimatedReaction( + () => isCursorOverClickable.get(), + (isOver) => { + scheduleOnRN(setIsOverClickableTarget, isOver); + }, + ); + /** * Hover gesture to be placed on the full-height outer container (chart + label area). * Clamps the y coordinate to chartBottom before passing to Victory so that hovering @@ -308,6 +326,8 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso activeDataIndex, /** Whether the tooltip should currently be rendered and visible */ isTooltipActive: isOverTarget && isTooltipActiveState, + /** Whether the cursor is over a clickable element (dot/bar, not label) */ + isOverClickableTarget, /** Raw tooltip positioning data */ initialTooltipPosition, }; From 94b63d980d07f5c885f7b8a37f017b9805a3e5b9 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 17 Mar 2026 16:09:43 +0100 Subject: [PATCH 10/25] Use selector for policies in SpendOverTimeSection --- src/pages/home/SpendOverTimeSection.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx index 09ce9223ea91..f245f021ac6a 100644 --- a/src/pages/home/SpendOverTimeSection.tsx +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -37,11 +37,12 @@ function SpendOverTimeSection() { const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {accountID, login} = useCurrentUserPersonalDetails(); + const [isAnyPolicyEligibleForSpendOverTime] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies) => Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)), + }); const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${QUERY_JSON?.hash}`); const {isOffline} = useNetwork(); - const isVisible = Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)); // We need the snapshot's isLoading in the search effect without subscribing to it (which would cause an infinite loop). // useLayoutEffect syncs the ref before useEffect runs. TODO: Replace with useEffectEvent after upgrading to React 19.2. @@ -52,7 +53,7 @@ function SpendOverTimeSection() { }, [searchResults?.search?.isLoading]); useEffect(() => { - if (!isVisible || isOffline || !QUERY_JSON || isSearchLoadingRef.current) { + if (!isAnyPolicyEligibleForSpendOverTime || isOffline || !QUERY_JSON || isSearchLoadingRef.current) { return; } search({ @@ -62,9 +63,9 @@ function SpendOverTimeSection() { isOffline: false, isLoading: false, }); - }, [isVisible, isOffline]); + }, [isAnyPolicyEligibleForSpendOverTime, isOffline]); - if (!isVisible || !QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE || !login) { + if (!isAnyPolicyEligibleForSpendOverTime || !QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE || !login) { return null; } From a68275ee2d4e208eee6445b6504f065619aead74 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 17 Mar 2026 16:37:44 +0100 Subject: [PATCH 11/25] Decompose SpendOverTimeSection --- src/pages/home/SpendOverTimeSection.tsx | 102 +++++++++++++++--------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx index f245f021ac6a..4ce4b399751e 100644 --- a/src/pages/home/SpendOverTimeSection.tsx +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -29,21 +29,13 @@ const GROUP_BY = QUERY_JSON?.groupBy; const VIEW = QUERY_JSON?.view; const SEARCH_KEY = CONFIG.key; -function SpendOverTimeSection() { - const styles = useThemeStyles(); +function useSpendOverTimeData() { const {translate, localeCompare, formatPhoneNumber} = useLocalize(); - const theme = useTheme(); - const icons = useMemoizedLazyExpensifyIcons(['Expand', 'OfflineCloud']); - const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {accountID, login} = useCurrentUserPersonalDetails(); - const [isAnyPolicyEligibleForSpendOverTime] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: (policies) => Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)), - }); - const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${QUERY_JSON?.hash}`); const {isOffline} = useNetwork(); + const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${QUERY_JSON?.hash}`); + // We need the snapshot's isLoading in the search effect without subscribing to it (which would cause an infinite loop). // useLayoutEffect syncs the ref before useEffect runs. TODO: Replace with useEffectEvent after upgrading to React 19.2. const isSearchLoadingRef = useRef(false); @@ -53,7 +45,7 @@ function SpendOverTimeSection() { }, [searchResults?.search?.isLoading]); useEffect(() => { - if (!isAnyPolicyEligibleForSpendOverTime || isOffline || !QUERY_JSON || isSearchLoadingRef.current) { + if (isOffline || !QUERY_JSON || isSearchLoadingRef.current) { return; } search({ @@ -63,40 +55,59 @@ function SpendOverTimeSection() { isOffline: false, isLoading: false, }); - }, [isAnyPolicyEligibleForSpendOverTime, isOffline]); + }, [isOffline]); - if (!isAnyPolicyEligibleForSpendOverTime || !QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE || !login) { - return null; - } - - const sortedData = searchResults?.data - ? (getSortedSections( - QUERY_JSON.type, - QUERY_JSON.status, - getSections({ - type: QUERY_JSON.type, - data: searchResults.data, - groupBy: GROUP_BY, - queryJSON: QUERY_JSON, - currentAccountID: accountID, - currentUserEmail: login, + const sortedData = + searchResults?.data && QUERY_JSON && GROUP_BY && login + ? (getSortedSections( + QUERY_JSON.type, + QUERY_JSON.status, + getSections({ + type: QUERY_JSON.type, + data: searchResults.data, + groupBy: GROUP_BY, + queryJSON: QUERY_JSON, + currentAccountID: accountID, + currentUserEmail: login, + translate, + formatPhoneNumber, + bankAccountList: undefined, + allReportMetadata: undefined, + })[0], + localeCompare, translate, - formatPhoneNumber, - bankAccountList: undefined, - allReportMetadata: undefined, - })[0], - localeCompare, - translate, - QUERY_JSON.sortBy, - QUERY_JSON.sortOrder, - GROUP_BY, - ) as GroupedItem[]) - : undefined; + QUERY_JSON.sortBy, + QUERY_JSON.sortOrder, + GROUP_BY, + ) as GroupedItem[]) + : undefined; const shouldShowOfflineIndicator = isOffline && !sortedData; const shouldShowErrorIndicator = !shouldShowOfflineIndicator && Object.keys(searchResults?.errors ?? {}).length > 0; const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, QUERY_JSON); + return { + sortedData, + shouldShowOfflineIndicator, + shouldShowErrorIndicator, + shouldShowLoadingIndicator, + }; +} + +function SpendOverTimeSectionContent() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['Expand', 'OfflineCloud']); + const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const {sortedData, shouldShowOfflineIndicator, shouldShowErrorIndicator, shouldShowLoadingIndicator} = useSpendOverTimeData(); + + if (!QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE) { + return null; + } + if (!shouldShowErrorIndicator && sortedData?.length === 0) { return null; } @@ -160,4 +171,17 @@ function SpendOverTimeSection() { ); } +function SpendOverTimeSection() { + const {login} = useCurrentUserPersonalDetails(); + const [isAnyPolicyEligibleForSpendOverTime] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies) => Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)), + }); + + if (!isAnyPolicyEligibleForSpendOverTime) { + return null; + } + + return ; +} + export default SpendOverTimeSection; From 23614a4eaa4e71a25d449f7e86b5460e7e52de49 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 17 Mar 2026 16:56:22 +0100 Subject: [PATCH 12/25] Don't show SpendOverTime widget when there are no transactions --- src/pages/home/SpendOverTimeSection.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx index 4ce4b399751e..99d78975b441 100644 --- a/src/pages/home/SpendOverTimeSection.tsx +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -176,8 +176,11 @@ function SpendOverTimeSection() { const [isAnyPolicyEligibleForSpendOverTime] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: (policies) => Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)), }); + const [hasTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + selector: (transactions) => Object.keys(transactions ?? {}).length > 0, + }); - if (!isAnyPolicyEligibleForSpendOverTime) { + if (!isAnyPolicyEligibleForSpendOverTime || !hasTransactions) { return null; } From 670d11908c28a4bae5e1e6a66c37ea63c784b4e0 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 18 Mar 2026 11:42:08 +0100 Subject: [PATCH 13/25] Add a comment about SpendOverTimeSection visibility --- src/pages/home/SpendOverTimeSection.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx index 99d78975b441..1967b98f2577 100644 --- a/src/pages/home/SpendOverTimeSection.tsx +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -180,6 +180,9 @@ function SpendOverTimeSection() { selector: (transactions) => Object.keys(transactions ?? {}).length > 0, }); + // The widget is only shown for workspace admins/auditors/approvers. + // If there are no transactions (e.g. a brand new account) we expect the Search results to be empty, + // so we don't show the section to avoid briefly displaying a loading widget that disappears once the empty results load. if (!isAnyPolicyEligibleForSpendOverTime || !hasTransactions) { return null; } From e9b467ef6a82f4bfe908d609e08ae507a0197bea Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 19 Mar 2026 10:03:37 +0100 Subject: [PATCH 14/25] Change left icon's right margin in small buttons --- src/components/Button/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index d773291b0fb9..0aa36d2fcf6c 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -369,7 +369,7 @@ function Button({ {!!icon && ( - + Date: Fri, 20 Mar 2026 12:21:55 +0100 Subject: [PATCH 15/25] Don't save last search params onyx value in search action when invoked from Home Page --- src/libs/SearchUIUtils.ts | 2 +- src/libs/actions/Search.ts | 46 ++++++++++++++----------- src/pages/home/SpendOverTimeSection.tsx | 1 + 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 2db3593600cf..9fb56b2358e6 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -996,7 +996,7 @@ function getSuggestedSearchesVisibility( const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; const isExporter = policy.exporter === currentUserEmail; - const isSubmittedTo = Object.values(policy.employeeList ?? {}).some((employee) => employee.submitsTo === currentUserEmail || employee.forwardsTo === currentUserEmail); + const isSubmittedTo = !!currentUserEmail && Object.values(policy.employeeList ?? {}).some((employee) => employee.submitsTo === currentUserEmail || employee.forwardsTo); const isUserApprover = !!currentUserEmail && isPolicyApprover(policy, currentUserEmail); const isApprovalEnabled = policy.approvalMode ? policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index b5a921f228bc..0e0424f48d9a 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -474,6 +474,7 @@ function search({ prevReportsLength, isOffline = false, isLoading, + shouldUpdateLastSearchParams = true, }: { queryJSON: SearchQueryJSON; searchKey: SearchKey | undefined; @@ -482,6 +483,7 @@ function search({ prevReportsLength?: number; isOffline?: boolean; isLoading: boolean; + shouldUpdateLastSearchParams?: boolean; }) { if (isLoading || shouldPreventSearchAPI) { return; @@ -500,11 +502,13 @@ function search({ }; const jsonQuery = JSON.stringify(query); - saveLastSearchParams({ - queryJSON, - offset, - allowPostSearchRecount: false, - }); + if (shouldUpdateLastSearchParams) { + saveLastSearchParams({ + queryJSON, + offset, + allowPostSearchRecount: false, + }); + } return waitForWrites(READ_COMMANDS.SEARCH).then(() => { // eslint-disable-next-line rulesdir/no-api-side-effects-method @@ -513,27 +517,29 @@ function search({ const reports = Object.keys(response?.data ?? {}) .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, '')); - if (response?.search?.offset) { - // Indicates that search results are extended from the Report view (with navigation between reports), - // using previous results to enable correct counter behavior. - if (prevReportsLength) { + if (shouldUpdateLastSearchParams) { + if (response?.search?.offset) { + // Indicates that search results are extended from the Report view (with navigation between reports), + // using previous results to enable correct counter behavior. + if (prevReportsLength) { + saveLastSearchParams({ + queryJSON, + offset, + hasMoreResults: !!response?.search?.hasMoreResults, + previousLengthOfResults: prevReportsLength, + allowPostSearchRecount: false, + }); + } + } else { + // Applies to all searches from the Search View saveLastSearchParams({ queryJSON, offset, hasMoreResults: !!response?.search?.hasMoreResults, - previousLengthOfResults: prevReportsLength, - allowPostSearchRecount: false, + previousLengthOfResults: reports.length, + allowPostSearchRecount: true, }); } - } else { - // Applies to all searches from the Search View - saveLastSearchParams({ - queryJSON, - offset, - hasMoreResults: !!response?.search?.hasMoreResults, - previousLengthOfResults: reports.length, - allowPostSearchRecount: true, - }); } return result?.jsonCode; diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx index 1967b98f2577..a358955f5d23 100644 --- a/src/pages/home/SpendOverTimeSection.tsx +++ b/src/pages/home/SpendOverTimeSection.tsx @@ -54,6 +54,7 @@ function useSpendOverTimeData() { offset: 0, isOffline: false, isLoading: false, + shouldUpdateLastSearchParams: false, }); }, [isOffline]); From 245cb16a154ec05de763ef0f69c238f6299a2c2a Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 20 Mar 2026 12:56:41 +0100 Subject: [PATCH 16/25] Divide SpendOverTimeSection into multiple files --- src/pages/home/SpendOverTimeSection.tsx | 194 ------------------ .../SpendOverTimeSectionContent.tsx | 97 +++++++++ src/pages/home/SpendOverTimeSection/config.ts | 12 ++ src/pages/home/SpendOverTimeSection/index.tsx | 27 +++ .../useSpendOverTimeData.ts | 78 +++++++ 5 files changed, 214 insertions(+), 194 deletions(-) delete mode 100644 src/pages/home/SpendOverTimeSection.tsx create mode 100644 src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx create mode 100644 src/pages/home/SpendOverTimeSection/config.ts create mode 100644 src/pages/home/SpendOverTimeSection/index.tsx create mode 100644 src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts diff --git a/src/pages/home/SpendOverTimeSection.tsx b/src/pages/home/SpendOverTimeSection.tsx deleted file mode 100644 index a358955f5d23..000000000000 --- a/src/pages/home/SpendOverTimeSection.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, {useEffect, useLayoutEffect, useRef} from 'react'; -import {View} from 'react-native'; -import BlockingView from '@components/BlockingViews/BlockingView'; -import Button from '@components/Button'; -import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/constants'; -import SearchChartView from '@components/Search/SearchChartView'; -import type {GroupedItem} from '@components/Search/types'; -import WidgetContainer from '@components/WidgetContainer'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {search} from '@libs/actions/Search'; -import Navigation from '@libs/Navigation/Navigation'; -import {getSections, getSortedSections, getSuggestedSearches, isPolicyEligibleForSpendOverTime, isSearchDataLoaded} from '@libs/SearchUIUtils'; -import variables from '@styles/variables'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -const CONFIG = getSuggestedSearches()[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; -const QUERY = CONFIG.searchQuery; -const QUERY_JSON = CONFIG.searchQueryJSON; -const GROUP_BY = QUERY_JSON?.groupBy; -const VIEW = QUERY_JSON?.view; -const SEARCH_KEY = CONFIG.key; - -function useSpendOverTimeData() { - const {translate, localeCompare, formatPhoneNumber} = useLocalize(); - const {accountID, login} = useCurrentUserPersonalDetails(); - const {isOffline} = useNetwork(); - - const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${QUERY_JSON?.hash}`); - - // We need the snapshot's isLoading in the search effect without subscribing to it (which would cause an infinite loop). - // useLayoutEffect syncs the ref before useEffect runs. TODO: Replace with useEffectEvent after upgrading to React 19.2. - const isSearchLoadingRef = useRef(false); - - useLayoutEffect(() => { - isSearchLoadingRef.current = !!searchResults?.search?.isLoading; - }, [searchResults?.search?.isLoading]); - - useEffect(() => { - if (isOffline || !QUERY_JSON || isSearchLoadingRef.current) { - return; - } - search({ - queryJSON: QUERY_JSON, - searchKey: SEARCH_KEY, - offset: 0, - isOffline: false, - isLoading: false, - shouldUpdateLastSearchParams: false, - }); - }, [isOffline]); - - const sortedData = - searchResults?.data && QUERY_JSON && GROUP_BY && login - ? (getSortedSections( - QUERY_JSON.type, - QUERY_JSON.status, - getSections({ - type: QUERY_JSON.type, - data: searchResults.data, - groupBy: GROUP_BY, - queryJSON: QUERY_JSON, - currentAccountID: accountID, - currentUserEmail: login, - translate, - formatPhoneNumber, - bankAccountList: undefined, - allReportMetadata: undefined, - })[0], - localeCompare, - translate, - QUERY_JSON.sortBy, - QUERY_JSON.sortOrder, - GROUP_BY, - ) as GroupedItem[]) - : undefined; - - const shouldShowOfflineIndicator = isOffline && !sortedData; - const shouldShowErrorIndicator = !shouldShowOfflineIndicator && Object.keys(searchResults?.errors ?? {}).length > 0; - const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, QUERY_JSON); - - return { - sortedData, - shouldShowOfflineIndicator, - shouldShowErrorIndicator, - shouldShowLoadingIndicator, - }; -} - -function SpendOverTimeSectionContent() { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const theme = useTheme(); - const icons = useMemoizedLazyExpensifyIcons(['Expand', 'OfflineCloud']); - const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - - const {sortedData, shouldShowOfflineIndicator, shouldShowErrorIndicator, shouldShowLoadingIndicator} = useSpendOverTimeData(); - - if (!QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE) { - return null; - } - - if (!shouldShowErrorIndicator && sortedData?.length === 0) { - return null; - } - - return ( - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: QUERY}))} - iconRight={icons.Expand} - shouldShowRightIcon - textStyles={styles.pb0} - style={styles.widgetItemButton} - isContentCentered - /> - ) - } - > - {shouldShowOfflineIndicator && ( - - )} - {shouldShowErrorIndicator && ( - - )} - {!shouldShowOfflineIndicator && !shouldShowErrorIndicator && ( - - - - )} - - ); -} - -function SpendOverTimeSection() { - const {login} = useCurrentUserPersonalDetails(); - const [isAnyPolicyEligibleForSpendOverTime] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: (policies) => Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)), - }); - const [hasTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { - selector: (transactions) => Object.keys(transactions ?? {}).length > 0, - }); - - // The widget is only shown for workspace admins/auditors/approvers. - // If there are no transactions (e.g. a brand new account) we expect the Search results to be empty, - // so we don't show the section to avoid briefly displaying a loading widget that disappears once the empty results load. - if (!isAnyPolicyEligibleForSpendOverTime || !hasTransactions) { - return null; - } - - return ; -} - -export default SpendOverTimeSection; diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx new file mode 100644 index 000000000000..0161f50d01d6 --- /dev/null +++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import {View} from 'react-native'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import Button from '@components/Button'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/constants'; +import SearchChartView from '@components/Search/SearchChartView'; +import WidgetContainer from '@components/WidgetContainer'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import {GROUP_BY, QUERY, QUERY_JSON, TRANSLATION_PATH, VIEW} from './config'; +import useSpendOverTimeData from './useSpendOverTimeData'; + +function SpendOverTimeSectionContent() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['Expand', 'OfflineCloud']); + const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const {sortedData, shouldShowOfflineIndicator, shouldShowErrorIndicator, shouldShowLoadingIndicator} = useSpendOverTimeData(); + + if (!QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE) { + return null; + } + + if (!shouldShowErrorIndicator && sortedData?.length === 0) { + return null; + } + + return ( + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: QUERY}))} + iconRight={icons.Expand} + shouldShowRightIcon + textStyles={styles.pb0} + style={styles.widgetItemButton} + isContentCentered + /> + ) + } + > + {shouldShowOfflineIndicator && ( + + )} + {shouldShowErrorIndicator && ( + + )} + {!shouldShowOfflineIndicator && !shouldShowErrorIndicator && ( + + + + )} + + ); +} + +export default SpendOverTimeSectionContent; diff --git a/src/pages/home/SpendOverTimeSection/config.ts b/src/pages/home/SpendOverTimeSection/config.ts new file mode 100644 index 000000000000..004fb9ac6521 --- /dev/null +++ b/src/pages/home/SpendOverTimeSection/config.ts @@ -0,0 +1,12 @@ +import {getSuggestedSearches} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; + +const CONFIG = getSuggestedSearches()[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; +const QUERY = CONFIG.searchQuery; +const QUERY_JSON = CONFIG.searchQueryJSON; +const GROUP_BY = QUERY_JSON?.groupBy; +const VIEW = QUERY_JSON?.view; +const SEARCH_KEY = CONFIG.key; +const TRANSLATION_PATH = CONFIG.translationPath; + +export {QUERY, QUERY_JSON, GROUP_BY, VIEW, SEARCH_KEY, TRANSLATION_PATH}; diff --git a/src/pages/home/SpendOverTimeSection/index.tsx b/src/pages/home/SpendOverTimeSection/index.tsx new file mode 100644 index 000000000000..fe9fc1468657 --- /dev/null +++ b/src/pages/home/SpendOverTimeSection/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import {isPolicyEligibleForSpendOverTime} from '@libs/SearchUIUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SpendOverTimeSectionContent from './SpendOverTimeSectionContent'; + +function SpendOverTimeSection() { + const {login} = useCurrentUserPersonalDetails(); + const [isAnyPolicyEligibleForSpendOverTime] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies) => Object.values(policies ?? {}).some((policy) => !!policy && isPolicyEligibleForSpendOverTime(policy, login)), + }); + const [hasTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + selector: (transactions) => Object.keys(transactions ?? {}).length > 0, + }); + + // The widget is only shown for workspace admins/auditors/approvers. + // If there are no transactions (e.g. a brand new account) we expect the Search results to be empty, + // so we don't show the section to avoid briefly displaying a loading widget that disappears once the empty results load. + if (!isAnyPolicyEligibleForSpendOverTime || !hasTransactions) { + return null; + } + + return ; +} + +export default SpendOverTimeSection; diff --git a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts new file mode 100644 index 000000000000..1809a2d3012d --- /dev/null +++ b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts @@ -0,0 +1,78 @@ +import {useEffect, useLayoutEffect, useRef} from 'react'; +import type {GroupedItem} from '@components/Search/types'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import {search} from '@libs/actions/Search'; +import {getSections, getSortedSections, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {GROUP_BY, QUERY_JSON, SEARCH_KEY} from './config'; + +function useSpendOverTimeData() { + const {translate, localeCompare, formatPhoneNumber} = useLocalize(); + const {accountID, login} = useCurrentUserPersonalDetails(); + const {isOffline} = useNetwork(); + + const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${QUERY_JSON?.hash}`); + + // We need the snapshot's isLoading in the search effect without subscribing to it (which would cause an infinite loop). + // useLayoutEffect syncs the ref before useEffect runs. TODO: Replace with useEffectEvent after upgrading to React 19.2. + const isSearchLoadingRef = useRef(false); + + useLayoutEffect(() => { + isSearchLoadingRef.current = !!searchResults?.search?.isLoading; + }, [searchResults?.search?.isLoading]); + + useEffect(() => { + if (isOffline || !QUERY_JSON || isSearchLoadingRef.current) { + return; + } + search({ + queryJSON: QUERY_JSON, + searchKey: SEARCH_KEY, + offset: 0, + isOffline: false, + isLoading: false, + shouldUpdateLastSearchParams: false, + }); + }, [isOffline]); + + const sortedData = + searchResults?.data && QUERY_JSON && GROUP_BY && login + ? (getSortedSections( + QUERY_JSON.type, + QUERY_JSON.status, + getSections({ + type: QUERY_JSON.type, + data: searchResults.data, + groupBy: GROUP_BY, + queryJSON: QUERY_JSON, + currentAccountID: accountID, + currentUserEmail: login, + translate, + formatPhoneNumber, + bankAccountList: undefined, + allReportMetadata: undefined, + })[0], + localeCompare, + translate, + QUERY_JSON.sortBy, + QUERY_JSON.sortOrder, + GROUP_BY, + ) as GroupedItem[]) + : undefined; + + const shouldShowOfflineIndicator = isOffline && !sortedData; + const shouldShowErrorIndicator = !shouldShowOfflineIndicator && Object.keys(searchResults?.errors ?? {}).length > 0; + const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, QUERY_JSON); + + return { + sortedData, + shouldShowOfflineIndicator, + shouldShowErrorIndicator, + shouldShowLoadingIndicator, + }; +} + +export default useSpendOverTimeData; From 4cf1b211348e731e15abc6f2cdf851080cc8cd48 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 20 Mar 2026 13:02:52 +0100 Subject: [PATCH 17/25] Center widget title with the right content button --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index f51056b57853..01cedf7e486d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6399,7 +6399,7 @@ const plainStyles = (theme: ThemeColors) => getWidgetContainerHeaderStyle: (shouldUseNarrowLayout: boolean) => ({ flexDirection: 'row', - alignItems: 'flex-start', + alignItems: 'center', marginBottom: 20, marginHorizontal: shouldUseNarrowLayout ? 20 : 32, marginTop: shouldUseNarrowLayout ? 20 : 32, From c1408327eadac5a602cb8ffe4823819b8c3134e2 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 20 Mar 2026 13:42:23 +0100 Subject: [PATCH 18/25] Add unit tests for isPolicyEligibleForSpendOverTime --- tests/unit/Search/SearchUIUtilsTest.ts | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 6c4bc59fc5e9..8de2f54d63b9 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -7932,4 +7932,51 @@ describe('SearchUIUtils', () => { }); }); }); + + describe('isPolicyEligibleForSpendOverTime', () => { + const userEmail = 'user@example.com'; + const approverUserEmail = 'approver@example.com'; + + const createPolicy = (overrides: Partial = {}) => ({ + id: 'testPolicyID', + name: 'Test Policy', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + owner: 'owner@example.com', + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + employeeList: {}, + ...overrides, + }); + + test('returns true for admin on a paid group policy', () => { + expect(SearchUIUtils.isPolicyEligibleForSpendOverTime(createPolicy({role: CONST.POLICY.ROLE.ADMIN}), userEmail)).toBe(true); + }); + + test('returns true for auditor on a paid group policy', () => { + expect(SearchUIUtils.isPolicyEligibleForSpendOverTime(createPolicy({role: CONST.POLICY.ROLE.AUDITOR}), userEmail)).toBe(true); + }); + + test('returns false for a personal policy even with admin role', () => { + expect(SearchUIUtils.isPolicyEligibleForSpendOverTime(createPolicy({type: CONST.POLICY.TYPE.PERSONAL, role: CONST.POLICY.ROLE.ADMIN}), userEmail)).toBe(false); + }); + + test.each([{field: 'submitsTo'}, {field: 'forwardsTo'}, {field: 'overLimitForwardsTo'}])('returns true for a member who is a policy approver via $field', ({field}) => { + const approverPolicy = createPolicy({ + employeeList: { + [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.USER, [field]: approverUserEmail}, + }, + }); + expect(SearchUIUtils.isPolicyEligibleForSpendOverTime(approverPolicy, approverUserEmail)).toBe(true); + }); + + test('returns false for a member who is not an approver', () => { + const regularPolicy = createPolicy({ + employeeList: { + [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.USER, submitsTo: 'someone.else@example.com'}, + }, + }); + expect(SearchUIUtils.isPolicyEligibleForSpendOverTime(regularPolicy, userEmail)).toBe(false); + }); + }); }); From e5a0a175ec70bc429e1b014517ce39cfa17a567b Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 20 Mar 2026 13:52:10 +0100 Subject: [PATCH 19/25] Fix after merge --- src/libs/actions/Search.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 6fe509242e23..9bd573427926 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -525,11 +525,11 @@ function search({ // eslint-disable-next-line rulesdir/no-api-side-effects-method return API.makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}) .then((result) => { - const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; - const reports = Object.keys(response?.data ?? {}) - .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) - .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, '')); if (shouldUpdateLastSearchParams) { + const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; + const reports = Object.keys(response?.data ?? {}) + .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) + .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, '')); if (response?.search?.offset) { // Indicates that search results are extended from the Report view (with navigation between reports), // using previous results to enable correct counter behavior. From 07262cf3e9b422c0e4c716a556664c18916eacbc Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 20 Mar 2026 14:36:53 +0100 Subject: [PATCH 20/25] Fix types after merge --- src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts index 1809a2d3012d..4376c98f6129 100644 --- a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts +++ b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts @@ -54,6 +54,7 @@ function useSpendOverTimeData() { formatPhoneNumber, bankAccountList: undefined, allReportMetadata: undefined, + conciergeReportID: undefined, })[0], localeCompare, translate, From e25a2d6c57c437c46873a55a49d206eaa72c9013 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Fri, 20 Mar 2026 15:25:24 +0100 Subject: [PATCH 21/25] Remove disableDynamicHeight from charts --- src/components/Charts/BarChart/BarChartContent.tsx | 6 +++--- src/components/Charts/BarChart/index.tsx | 3 +-- src/components/Charts/LineChart/LineChartContent.tsx | 6 +++--- src/components/Charts/LineChart/index.tsx | 3 +-- src/components/Charts/PieChart/PieChartContent.tsx | 2 +- src/components/Charts/types.ts | 3 --- src/components/Search/SearchBarChart.tsx | 3 +-- src/components/Search/SearchChartView.tsx | 6 +----- src/components/Search/SearchLineChart.tsx | 3 +-- src/components/Search/types.ts | 3 --- .../SpendOverTimeSection/SpendOverTimeSectionContent.tsx | 1 - src/styles/index.ts | 9 ++++++++- 12 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index aa4c11d4daa6..00e3ac0ce544 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -73,7 +73,7 @@ type BarChartProps = CartesianChartProps & { useSingleColor?: boolean; }; -function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress, disableDynamicHeight}: BarChartProps) { +function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { const theme = useTheme(); const styles = useThemeStyles(); const font = useFont(fontSource, variables.iconSizeExtraSmall); @@ -229,13 +229,13 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left' }; const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); - const dynamicChartStyle = {height: disableDynamicHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'BarChartContent', isLoading, isFontLoading: !font}; return ( - + + void; }; -function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress, disableDynamicHeight}: LineChartProps) { +function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { const theme = useTheme(); const styles = useThemeStyles(); const font = useFont(fontSource, variables.iconSizeExtraSmall); @@ -226,13 +226,13 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left }; const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); - const dynamicChartStyle = {height: disableDynamicHeight ? CHART_CONTENT_MIN_HEIGHT : CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'LineChartContent', isLoading, isFontLoading: !font}; return ( - + + + { const currency = item.currency ?? 'USD'; const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); @@ -36,7 +36,6 @@ function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading, onBarPress={handleBarPress} yAxisUnit={unit} yAxisUnitPosition={unitPosition} - disableDynamicHeight={disableDynamicHeight} /> ); } diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index e2b35d5e43fe..d5af955af6bb 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -27,9 +27,6 @@ type SearchChartViewProps = { /** Whether data is loading */ isLoading?: boolean; - - /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. (line and bar charts only) */ - disableDynamicHeight?: boolean; }; /** @@ -45,7 +42,7 @@ const CHART_VIEW_TO_COMPONENT: Record ); } diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index a5f5a9f5a0a5..4c73e8bfde14 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -4,7 +4,7 @@ import type {ChartDataPoint} from '@components/Charts'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type {SearchChartProps} from './types'; -function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition, disableDynamicHeight}: SearchChartProps) { +function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); @@ -36,7 +36,6 @@ function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading onPointPress={handlePointPress} yAxisUnit={unit} yAxisUnitPosition={unitPosition} - disableDynamicHeight={disableDynamicHeight} /> ); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index fda12abd4c21..46891a089a04 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -365,9 +365,6 @@ type SearchChartProps = { /** Position of currency symbol relative to value */ unitPosition?: UnitPosition; - - /** When true, the overall chart container height remains fixed and the plot area shrinks to make room for x-axis labels instead of the container growing taller. */ - disableDynamicHeight?: boolean; }; export type { diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx index 0161f50d01d6..29c914423ec2 100644 --- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx +++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx @@ -86,7 +86,6 @@ function SpendOverTimeSectionContent() { groupBy={GROUP_BY} data={sortedData ?? []} isLoading={shouldShowLoadingIndicator} - disableDynamicHeight /> )} diff --git a/src/styles/index.ts b/src/styles/index.ts index 52cf7b62c2a7..4b1df0c75b4d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -11,6 +11,7 @@ import type {SharedValue} from 'react-native-reanimated'; import {interpolate} from 'react-native-reanimated'; import type {MixedStyleDeclaration, MixedStyleRecord} from 'react-native-render-html'; import type {ValueOf} from 'type-fest'; +import {CHART_CONTENT_MIN_HEIGHT} from '@components/Charts/constants'; import type DotLottieAnimation from '@components/LottieAnimations/types'; import {ACTIVE_LABEL_SCALE} from '@components/TextInput/styleConst'; import {animatedReceiptPaneRHPWidth, animatedSuperWideRHPWidth, animatedWideRHPWidth} from '@components/WideRHPContextProvider'; @@ -5816,6 +5817,7 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.highlightBG, borderRadius: variables.componentBorderRadiusLarge, padding: 20, + minHeight: CHART_CONTENT_MIN_HEIGHT, }, chartHeader: { flexDirection: 'row', @@ -5854,7 +5856,12 @@ const staticStyles = (theme: ThemeColors) => borderRadius: variables.componentBorderRadiusLarge, }, chartContent: { - minHeight: 250, + minHeight: CHART_CONTENT_MIN_HEIGHT, + }, + chartActivityIndicator: { + minHeight: CHART_CONTENT_MIN_HEIGHT, + justifyContent: 'center', + alignItems: 'center', }, pieChartLegendContainer: { display: 'flex', From 265a77e53ec569cfbd8032aacb8fbc81b27f0b50 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 24 Mar 2026 10:01:04 +0100 Subject: [PATCH 22/25] Fix import --- src/components/Search/chartGroupByConfig.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Search/chartGroupByConfig.ts b/src/components/Search/chartGroupByConfig.ts index 9375475b39e0..62b0f23a92fd 100644 --- a/src/components/Search/chartGroupByConfig.ts +++ b/src/components/Search/chartGroupByConfig.ts @@ -1,3 +1,5 @@ +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -9,9 +11,7 @@ import type { TransactionWeekGroupListItemType, TransactionWithdrawalIDGroupListItemType, TransactionYearGroupListItemType, -} from '@components/SelectionListWithSections/types'; -import DateUtils from '@libs/DateUtils'; -import CONST from '@src/CONST'; +} from './SearchList/ListItem/types'; import type {GroupedItem, SearchGroupBy} from './types'; type ChartGroupByConfig = { From d7e6aa88e4026bb2574de3a341c48b24571491a6 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 24 Mar 2026 10:16:04 +0100 Subject: [PATCH 23/25] Fix isSubmittedTo --- src/libs/SearchUIUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 90bb5d64b17c..5175ab26b562 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1003,7 +1003,8 @@ function getSuggestedSearchesVisibility( const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; const isExporter = policy.exporter === currentUserEmail; - const isSubmittedTo = !!currentUserEmail && Object.values(policy.employeeList ?? {}).some((employee) => employee.submitsTo === currentUserEmail || employee.forwardsTo); + const isSubmittedTo = + !!currentUserEmail && Object.values(policy.employeeList ?? {}).some((employee) => employee.submitsTo === currentUserEmail || employee.forwardsTo === currentUserEmail); const isUserApprover = !!currentUserEmail && isPolicyApprover(policy, currentUserEmail); const isApprovalEnabled = policy.approvalMode ? policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; From 3d87ada29430b7587ed272648be3ba6fa76548e2 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 24 Mar 2026 11:13:58 +0100 Subject: [PATCH 24/25] Invoke getSuggestedSearches during render in SpendOverTimeSection --- .../SpendOverTimeSectionContent.tsx | 15 ++--- src/pages/home/SpendOverTimeSection/config.ts | 12 ---- .../useSpendOverTimeData.ts | 65 ++++++++++--------- 3 files changed, 43 insertions(+), 49 deletions(-) delete mode 100644 src/pages/home/SpendOverTimeSection/config.ts diff --git a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx index 29c914423ec2..aab074c23316 100644 --- a/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx +++ b/src/pages/home/SpendOverTimeSection/SpendOverTimeSectionContent.tsx @@ -14,7 +14,6 @@ import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import {GROUP_BY, QUERY, QUERY_JSON, TRANSLATION_PATH, VIEW} from './config'; import useSpendOverTimeData from './useSpendOverTimeData'; function SpendOverTimeSectionContent() { @@ -25,9 +24,9 @@ function SpendOverTimeSectionContent() { const illustrations = useMemoizedLazyIllustrations(['BrokenMagnifyingGlass']); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {sortedData, shouldShowOfflineIndicator, shouldShowErrorIndicator, shouldShowLoadingIndicator} = useSpendOverTimeData(); + const {query, queryJSON, groupBy, view, sortedData, shouldShowOfflineIndicator, shouldShowErrorIndicator, shouldShowLoadingIndicator} = useSpendOverTimeData(); - if (!QUERY_JSON || !VIEW || !GROUP_BY || VIEW === CONST.SEARCH.VIEW.TABLE) { + if (!queryJSON || !view || !groupBy || view === CONST.SEARCH.VIEW.TABLE) { return null; } @@ -37,13 +36,13 @@ function SpendOverTimeSectionContent() { return ( Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: QUERY}))} + onPress={() => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query}))} iconRight={icons.Expand} shouldShowRightIcon textStyles={styles.pb0} @@ -81,9 +80,9 @@ function SpendOverTimeSectionContent() { {!shouldShowOfflineIndicator && !shouldShowErrorIndicator && ( diff --git a/src/pages/home/SpendOverTimeSection/config.ts b/src/pages/home/SpendOverTimeSection/config.ts deleted file mode 100644 index 004fb9ac6521..000000000000 --- a/src/pages/home/SpendOverTimeSection/config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {getSuggestedSearches} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; - -const CONFIG = getSuggestedSearches()[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; -const QUERY = CONFIG.searchQuery; -const QUERY_JSON = CONFIG.searchQueryJSON; -const GROUP_BY = QUERY_JSON?.groupBy; -const VIEW = QUERY_JSON?.view; -const SEARCH_KEY = CONFIG.key; -const TRANSLATION_PATH = CONFIG.translationPath; - -export {QUERY, QUERY_JSON, GROUP_BY, VIEW, SEARCH_KEY, TRANSLATION_PATH}; diff --git a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts index 4376c98f6129..90bfd03978d7 100644 --- a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts +++ b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts @@ -1,53 +1,56 @@ -import {useEffect, useLayoutEffect, useRef} from 'react'; +import {useEffect, useEffectEvent} from 'react'; import type {GroupedItem} from '@components/Search/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {search} from '@libs/actions/Search'; -import {getSections, getSortedSections, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import {getSections, getSortedSections, getSuggestedSearches, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {GROUP_BY, QUERY_JSON, SEARCH_KEY} from './config'; function useSpendOverTimeData() { + const config = getSuggestedSearches()[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; + const {searchQueryJSON: queryJSON, searchQuery: query, key: searchKey} = config; + const {groupBy, view} = queryJSON ?? {}; + const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const {accountID, login} = useCurrentUserPersonalDetails(); - const {isOffline} = useNetwork(); - - const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${QUERY_JSON?.hash}`); - - // We need the snapshot's isLoading in the search effect without subscribing to it (which would cause an infinite loop). - // useLayoutEffect syncs the ref before useEffect runs. TODO: Replace with useEffectEvent after upgrading to React 19.2. - const isSearchLoadingRef = useRef(false); + const [searchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON?.hash}`); + const isSearchLoading = !!searchResults?.search?.isLoading; - useLayoutEffect(() => { - isSearchLoadingRef.current = !!searchResults?.search?.isLoading; - }, [searchResults?.search?.isLoading]); - - useEffect(() => { - if (isOffline || !QUERY_JSON || isSearchLoadingRef.current) { + const fetchData = () => { + if (!queryJSON || isSearchLoading) { return; } search({ - queryJSON: QUERY_JSON, - searchKey: SEARCH_KEY, + queryJSON, + searchKey, offset: 0, isOffline: false, isLoading: false, shouldUpdateLastSearchParams: false, }); - }, [isOffline]); + }; + + const onMount = useEffectEvent(fetchData); + + useEffect(() => { + onMount(); + }, [config.hash]); + + const {isOffline} = useNetwork({onReconnect: fetchData}); const sortedData = - searchResults?.data && QUERY_JSON && GROUP_BY && login + searchResults?.data && queryJSON && groupBy && login ? (getSortedSections( - QUERY_JSON.type, - QUERY_JSON.status, + queryJSON.type, + queryJSON.status, getSections({ - type: QUERY_JSON.type, + type: queryJSON.type, data: searchResults.data, - groupBy: GROUP_BY, - queryJSON: QUERY_JSON, + groupBy, + queryJSON, currentAccountID: accountID, currentUserEmail: login, translate, @@ -58,17 +61,21 @@ function useSpendOverTimeData() { })[0], localeCompare, translate, - QUERY_JSON.sortBy, - QUERY_JSON.sortOrder, - GROUP_BY, + queryJSON.sortBy, + queryJSON.sortOrder, + groupBy, ) as GroupedItem[]) : undefined; const shouldShowOfflineIndicator = isOffline && !sortedData; const shouldShowErrorIndicator = !shouldShowOfflineIndicator && Object.keys(searchResults?.errors ?? {}).length > 0; - const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, QUERY_JSON); + const shouldShowLoadingIndicator = !shouldShowOfflineIndicator && !shouldShowErrorIndicator && !isSearchDataLoaded(searchResults, queryJSON); return { + query, + queryJSON, + groupBy, + view, sortedData, shouldShowOfflineIndicator, shouldShowErrorIndicator, From 783e33cb61554b297b22300d2f00fadf05687670 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 24 Mar 2026 11:16:03 +0100 Subject: [PATCH 25/25] Rename effect event --- src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts index 90bfd03978d7..60e01ba6c3fe 100644 --- a/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts +++ b/src/pages/home/SpendOverTimeSection/useSpendOverTimeData.ts @@ -33,10 +33,10 @@ function useSpendOverTimeData() { }); }; - const onMount = useEffectEvent(fetchData); + const onConfigChanged = useEffectEvent(fetchData); useEffect(() => { - onMount(); + onConfigChanged(); }, [config.hash]); const {isOffline} = useNetwork({onReconnect: fetchData});