Repository navigation
Add local CSV downloads to Insights charts #104272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /** Escapes CSV text fields and prevents spreadsheet formula injection. */ | ||
| const CSV_FORMULA_PREFIX_REGEXP = /^(?:[\t\r\n]|\s*[=+\-@])/; | ||
|
|
||
| function escapeCsvField(value: string): string { | ||
| const safeValue = CSV_FORMULA_PREFIX_REGEXP.test(value) ? `'${value}` : value; | ||
| return /[",\r\n]/.test(safeValue) ? `"${safeValue.replaceAll('"', '""')}"` : safeValue; | ||
| } | ||
|
|
||
| export default escapeCsvField; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| /** Builds a CSV from the current period's loaded chart groups, without exporting individual expenses. */ | ||
| import type {LocalizedTranslate} from '@components/LocaleContextProvider'; | ||
| import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; | ||
| import type {GroupedItem, SearchGroupBy} from '@components/Search/types'; | ||
|
|
||
| import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; | ||
| import escapeCsvField from '@libs/escapeCsvField'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
| import type {TranslationPaths} from '@src/languages/types'; | ||
|
|
||
| import type {Interval} from 'date-fns'; | ||
|
|
||
| import {eachDayOfInterval, eachMonthOfInterval, eachQuarterOfInterval, eachWeekOfInterval, eachYearOfInterval, format, parseISO} from 'date-fns'; | ||
|
|
||
| type BuildInsightsChartCSVParams = { | ||
| data: GroupedItem[]; | ||
| groupBy: SearchGroupBy; | ||
| dateRange: {start?: string; end?: string}; | ||
| currency: string; | ||
| getCurrencyDecimals: (currency: string) => number; | ||
| translate: LocalizedTranslate; | ||
| }; | ||
|
|
||
| const GROUP_HEADER_KEYS: Partial<Record<SearchGroupBy, TranslationPaths>> = { | ||
| [CONST.SEARCH.GROUP_BY.FROM]: 'common.from', | ||
| [CONST.SEARCH.GROUP_BY.MERCHANT]: 'common.merchant', | ||
| [CONST.SEARCH.GROUP_BY.CATEGORY]: 'common.category', | ||
| }; | ||
|
|
||
| function getPeriodStarts(groupBy: SearchGroupBy, interval: Interval): Date[] { | ||
| switch (groupBy) { | ||
| case CONST.SEARCH.GROUP_BY.DAY: | ||
| return eachDayOfInterval(interval); | ||
| case CONST.SEARCH.GROUP_BY.WEEK: | ||
| // Search weeks start on Sunday, regardless of the display locale. | ||
| return eachWeekOfInterval(interval, {weekStartsOn: 0}); | ||
| case CONST.SEARCH.GROUP_BY.MONTH: | ||
| return eachMonthOfInterval(interval); | ||
| case CONST.SEARCH.GROUP_BY.QUARTER: | ||
| return eachQuarterOfInterval(interval); | ||
| case CONST.SEARCH.GROUP_BY.YEAR: | ||
| return eachYearOfInterval(interval); | ||
| default: | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| function buildInsightsChartCSV({data, groupBy, dateRange, currency, getCurrencyDecimals, translate}: BuildInsightsChartCSVParams): string { | ||
| const {getLabel, getDateRange} = CHART_GROUP_BY_CONFIG[groupBy]; | ||
| const groupHeader = translate(GROUP_HEADER_KEYS[groupBy] ?? 'common.date'); | ||
| const header = [groupHeader, translate('common.expenses'), translate('common.total'), translate('common.currency')].map(escapeCsvField).join(','); | ||
|
|
||
| let rows = data.map((item) => ({ | ||
| label: getDateRange?.(item)?.start ?? getLabel(item), | ||
| count: item.count ?? 0, | ||
| total: item.total ?? 0, | ||
| currency: item.currency ?? currency, | ||
| })); | ||
|
|
||
| if (getDateRange && dateRange.start && dateRange.end) { | ||
| const rowsByDate = new Map(rows.map((row) => [row.label, row])); | ||
| rows = getPeriodStarts(groupBy, {start: parseISO(dateRange.start), end: parseISO(dateRange.end)}).map((date) => { | ||
| const label = format(date, CONST.DATE.FNS_FORMAT_STRING); | ||
| return rowsByDate.get(label) ?? {label, count: 0, total: 0, currency}; | ||
| }); | ||
| } | ||
|
|
||
| const csvRows = rows.map((row) => | ||
| [escapeCsvField(row.label), row.count, convertToFrontendAmountAsString(row.total, getCurrencyDecimals(row.currency)), escapeCsvField(row.currency)].join(','), | ||
| ); | ||
|
|
||
| return `\uFEFF${[header, ...csvRows].join('\r\n')}`; | ||
| } | ||
|
|
||
| export default buildInsightsChartCSV; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,14 +7,18 @@ import type {SearchQueryJSON} from '@components/Search/types'; | |
| import WidgetContainer from '@components/WidgetContainer'; | ||
| import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; | ||
|
|
||
| import {useCurrencyListActions} from '@hooks/useCurrencyList'; | ||
| import useLayoutSpacing from '@hooks/useLayoutSpacing'; | ||
| import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; | ||
| import useLocalize from '@hooks/useLocalize'; | ||
| import useThemeStyles from '@hooks/useThemeStyles'; | ||
|
|
||
| import localFileDownload from '@libs/localFileDownload'; | ||
| import Navigation from '@libs/Navigation/Navigation'; | ||
| import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; | ||
| import {getDateFilterRange} from '@libs/SearchQueryUtils'; | ||
|
|
||
| import buildInsightsChartCSV from '@pages/Insights/buildInsightsChartCSV'; | ||
| import InsightsGroupByDropdown from '@pages/Insights/controls/InsightsGroupByDropdown'; | ||
| import type {InsightsChartSpec} from '@pages/Insights/dashboardSpecs'; | ||
| import type {InsightsFilters} from '@pages/Insights/insightsFilters'; | ||
|
|
@@ -78,7 +82,8 @@ function InsightsChartWidget({ | |
| const styles = useThemeStyles(); | ||
| const {translate} = useLocalize(); | ||
| const {cardPaddingHorizontal, cardPaddingBottom} = useLayoutSpacing(); | ||
| const icons = useMemoizedLazyExpensifyIcons(['Expand']); | ||
| const icons = useMemoizedLazyExpensifyIcons(['Expand', 'Table']); | ||
| const {getCurrencyDecimals} = useCurrencyListActions(); | ||
|
|
||
| const {data, state, comparison} = useInsightsChartData({chart, queryJSON, snapshot, previousPeriodQueryJSON, previousPeriodSnapshot, averageQueryJSON, averageSnapshot}, filters); | ||
| const groupBy = chart.groupBy ?? getQueryGroupBy(filters); | ||
|
|
@@ -88,6 +93,15 @@ function InsightsChartWidget({ | |
|
|
||
| const groupByOptions = getInsightsGroupByOptions(filters.date); | ||
|
|
||
| const downloadCSV = () => { | ||
| if (!queryJSON || state !== INSIGHTS_CHART_STATE.READY) { | ||
| return; | ||
| } | ||
|
|
||
| const content = buildInsightsChartCSV({data, groupBy, dateRange: getDateFilterRange(queryJSON), currency: filters.groupCurrency, getCurrencyDecimals, translate}); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Screen.Recording.2026-10-17.at.16.59.51.mov
@luacmartins Issue says the ranking export should follow View on Spend (no limit), but we export data, which is capped at 5 — is top 5 intended, or should it include every group with spend?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, I think top 5 is intended here since that's what the chart shows
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, top 5 is intended. |
||
| localFileDownload(`Expensify-${chart.graphKey}.csv`, content, translate); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
@luacmartins The issue says the download should reuse the existing export flow (Concierge for large files), but this generates the file locally with localFileDownload — is the local approach intended, e.g. to support offline export?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, we changed the approach to local only since that allows for offline export of the data that's already available. cc @JmillsExpensify
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct. We changed it and I never updated the OP, sorry. |
||
| }; | ||
|
|
||
| const groupByControl = | ||
| onGroupByChange && groupByOptions.length > 1 ? ( | ||
| <InsightsGroupByDropdown | ||
|
|
@@ -110,6 +124,16 @@ function InsightsChartWidget({ | |
| onSelected: () => Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildViewOnSpendQuery(queryJSON)})), | ||
| shouldCallAfterModalHide: true, | ||
| }, | ||
| ...(state === INSIGHTS_CHART_STATE.READY | ||
| ? [ | ||
| { | ||
| text: translate('spreadsheet.downloadCSV'), | ||
| icon: icons.Table, | ||
| onSelected: downloadCSV, | ||
| shouldCallAfterModalHide: true, | ||
| }, | ||
| ] | ||
| : []), | ||
| ]} | ||
| /> | ||
| ) : null; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import type {LocalizedTranslate} from '@components/LocaleContextProvider'; | ||
|
|
||
| import {exportExpensifyCardListToCSV} from '@libs/actions/Card'; | ||
| import localFileDownload from '@libs/localFileDownload'; | ||
| import {translate as translatePhrase} from '@libs/Localize'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
| import IntlStore from '@src/languages/IntlStore'; | ||
|
|
||
| import {createRandomExpensifyCard} from '../utils/collections/card'; | ||
|
|
||
| jest.mock('@libs/localFileDownload', () => jest.fn()); | ||
|
|
||
| const translate: LocalizedTranslate = (key, ...parameters) => translatePhrase(CONST.LOCALES.EN, key, ...parameters); | ||
|
|
||
| describe('exportExpensifyCardListToCSV', () => { | ||
| beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); | ||
|
|
||
| it('escapes cardholder names with the shared formula protection', () => { | ||
| // Given a cardholder name that could otherwise become a spreadsheet formula | ||
| const accountID = 1; | ||
| const card = {...createRandomExpensifyCard(1, {accountID}), lastFourPAN: '1234'}; | ||
|
|
||
| // When the selected card is downloaded as CSV | ||
| exportExpensifyCardListToCSV({ | ||
| policyID: 'policy123', | ||
| cards: [card], | ||
| personalDetailsList: {[accountID]: {accountID, login: '[email protected]', displayName: '=SUM(1,2)'}}, | ||
| settlementCurrency: CONST.CURRENCY.USD, | ||
| defaultLimitType: CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY, | ||
| translate, | ||
| formatPhoneNumber: (login) => login, | ||
| }); | ||
|
|
||
| // Then the name stays in one text cell instead of executing as a formula | ||
| expect(localFileDownload).toHaveBeenCalledWith('ExpensifyCards_policy123.csv', expect.stringContaining('\r\[email protected],"\'=SUM(1,2)",1234,'), translate); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import {render} from '@testing-library/react-native'; | ||
|
|
||
| import ComposeProviders from '@components/ComposeProviders'; | ||
| import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; | ||
| import {LocaleContextProvider} from '@components/LocaleContextProvider'; | ||
| import OnyxListItemProvider from '@components/OnyxListItemProvider'; | ||
| import type {GroupedItem} from '@components/Search/types'; | ||
| import WidgetHeaderMenu from '@components/WidgetHeaderMenu'; | ||
|
|
||
| import localFileDownload from '@libs/localFileDownload'; | ||
| import {INSIGHTS_CHART_STATE} from '@libs/resolveInsightsChartData'; | ||
| import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; | ||
|
|
||
| import InsightsChartWidget from '@pages/Insights/charts/InsightsChartWidget'; | ||
| import INSIGHTS_DASHBOARD_SPECS from '@pages/Insights/dashboardSpecs'; | ||
| import type {InsightsFilters} from '@pages/Insights/insightsFilters'; | ||
| import useInsightsChartData from '@pages/Insights/useInsightsChartData'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
|
|
||
| import React from 'react'; | ||
| import Onyx from 'react-native-onyx'; | ||
|
|
||
| import currencyList from '../unit/currencyList.json'; | ||
| import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; | ||
|
|
||
| jest.mock('@components/WidgetHeaderMenu', () => jest.fn(() => null)); | ||
| jest.mock('@components/Search/SearchChartView', () => jest.fn(() => null)); | ||
| jest.mock('@libs/localFileDownload', () => jest.fn()); | ||
| jest.mock('@libs/Navigation/Navigation'); | ||
| jest.mock('@pages/Insights/useInsightsChartData', () => ({__esModule: true, default: jest.fn()})); | ||
|
|
||
| const CHART = INSIGHTS_DASHBOARD_SPECS[CONST.INSIGHTS.DASHBOARD.SPEND].headlineChart; | ||
| const QUERY = buildSearchQueryJSON('type:expense group-by:month date>=2026-01-01 date<=2026-03-31 group-currency:USD'); | ||
| const FILTERS: InsightsFilters = {date: {from: '2026-01-01', to: '2026-03-31'}, policyIDs: [], groupBy: CONST.SEARCH.GROUP_BY.MONTH, groupCurrency: CONST.CURRENCY.USD}; | ||
| const DATA: GroupedItem[] = [ | ||
| { | ||
| groupedBy: CONST.SEARCH.GROUP_BY.MONTH, | ||
| year: 2026, | ||
| month: 2, | ||
| keyForList: 'group_february', | ||
| count: 2, | ||
| total: 12345, | ||
| currency: CONST.CURRENCY.USD, | ||
| transactions: [], | ||
| formattedMonth: 'February 2026', | ||
| shortFormattedMonth: 'Feb', | ||
| sortKey: 202602, | ||
| }, | ||
| ]; | ||
|
|
||
| function renderWidget() { | ||
| return render( | ||
| <ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider, CurrencyListContextProvider]}> | ||
| <InsightsChartWidget | ||
| chart={CHART} | ||
| queryJSON={QUERY} | ||
| snapshot={undefined} | ||
| previousPeriodQueryJSON={undefined} | ||
| previousPeriodSnapshot={undefined} | ||
| averageQueryJSON={undefined} | ||
| averageSnapshot={undefined} | ||
| filters={FILTERS} | ||
| onRetry={jest.fn()} | ||
| /> | ||
| </ComposeProviders>, | ||
| ); | ||
| } | ||
|
|
||
| describe('Insights chart CSV action', () => { | ||
| beforeAll(() => Onyx.init({keys: ONYXKEYS})); | ||
|
|
||
| beforeEach(async () => { | ||
| jest.clearAllMocks(); | ||
| await Onyx.set(ONYXKEYS.CURRENCY_LIST, currencyList); | ||
| await waitForBatchedUpdatesWithAct(); | ||
| jest.mocked(useInsightsChartData).mockReturnValue({data: DATA, state: INSIGHTS_CHART_STATE.READY, comparison: undefined}); | ||
| }); | ||
|
|
||
| afterEach(() => Onyx.clear()); | ||
|
|
||
| it('offers a local CSV below View on Spend and exports only the current period', async () => { | ||
| // Given a ready chart compared against a different set of totals | ||
| jest.mocked(useInsightsChartData).mockReturnValue({ | ||
| data: DATA, | ||
| state: INSIGHTS_CHART_STATE.READY, | ||
| comparison: {data: DATA.map((item) => ({...item, total: 99999})), label: 'Average', color: '#000000'}, | ||
| }); | ||
|
|
||
| // When the user selects Download CSV | ||
| renderWidget(); | ||
| await waitForBatchedUpdatesWithAct(); | ||
| const menuItems = jest.mocked(WidgetHeaderMenu).mock.lastCall?.[0].menuItems; | ||
| expect(menuItems?.map((item) => item.text)).toEqual(['View on Spend', 'Download CSV']); | ||
| menuItems?.at(1)?.onSelected?.(); | ||
|
|
||
| // Then the existing local download helper receives only grouped current-period values and zero-filled gaps | ||
| expect(localFileDownload).toHaveBeenCalledWith( | ||
| `Expensify-${CHART.graphKey}.csv`, | ||
| '\uFEFFDate,Expenses,Total,Currency\r\n2026-01-01,0,0.00,USD\r\n2026-02-01,2,123.45,USD\r\n2026-03-01,0,0.00,USD', | ||
| expect.any(Function), | ||
| ); | ||
| }); | ||
|
|
||
| it.each([INSIGHTS_CHART_STATE.LOADING, INSIGHTS_CHART_STATE.EMPTY, INSIGHTS_CHART_STATE.ERROR, INSIGHTS_CHART_STATE.OFFLINE])( | ||
| 'hides Download CSV while the chart is %s', | ||
| async (state) => { | ||
| // Given chart data that is not ready to export | ||
| jest.mocked(useInsightsChartData).mockReturnValue({data: [], state, comparison: undefined}); | ||
|
|
||
| // When its header is rendered | ||
| renderWidget(); | ||
| await waitForBatchedUpdatesWithAct(); | ||
| const menuItems = jest.mocked(WidgetHeaderMenu).mock.lastCall?.[0].menuItems ?? []; | ||
|
|
||
| // Then no stale or incomplete data can be downloaded, while View on Spend remains available during loading | ||
| expect(menuItems.map((item) => item.text)).toEqual(state === INSIGHTS_CHART_STATE.LOADING ? ['View on Spend'] : []); | ||
| expect(localFileDownload).not.toHaveBeenCalled(); | ||
| }, | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,21 @@ | ||
| import {render, screen} from '@testing-library/react-native'; | ||
|
|
||
| import WorkspaceCompanyCardsTableControls from '@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableControls'; | ||
| import type {WorkspaceCompanyCardTableItemData} from '@components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow'; | ||
|
|
||
| import {formatMaskedCardName} from '@libs/CardUtils'; | ||
| import localFileDownload from '@libs/localFileDownload'; | ||
|
|
||
| import React from 'react'; | ||
|
|
||
| type MockButtonProps = { | ||
| customText: string; | ||
| isDisabled?: boolean; | ||
| options?: Array<{value: string; onSelected?: () => void | Promise<void>}>; | ||
| }; | ||
|
|
||
| const mockButtonProps: {current?: MockButtonProps} = {}; | ||
| let mockProcessedData: Array<{selected?: boolean; disabled?: boolean; isAssigned?: boolean}> = []; | ||
| let mockProcessedData: Array<Partial<WorkspaceCompanyCardTableItemData> & {selected?: boolean}> = []; | ||
| let mockShouldUseNarrowLayout = true; | ||
|
|
||
| jest.mock('@components/ButtonWithDropdownMenu', () => { | ||
|
|
@@ -127,4 +132,17 @@ describe('WorkspaceCompanyCardsTableControls narrow-layout selection mode', () = | |
| expect(screen.queryByTestId('WorkspaceCompanyCardsBulkActions')).toBeNull(); | ||
| expect(screen.getByTestId('WorkspaceCompanyCardsFilterBar')).toBeTruthy(); | ||
| }); | ||
|
|
||
| it('exports formula-like card names as quoted text', async () => { | ||
| // Given a selected company card with a formula-like custom name | ||
| mockProcessedData = [{selected: true, isAssigned: true, cardName: 'card123', customCardName: '=SUM(1,2)', cardholder: {accountID: 1, login: '[email protected]'}}]; | ||
| jest.mocked(formatMaskedCardName).mockReturnValue('1234'); | ||
| renderControls(true); | ||
|
|
||
| // When the CSV export action is selected | ||
| await mockButtonProps.current?.options?.find((option) => option.value === 'exportCSV')?.onSelected?.(); | ||
|
|
||
| // Then the existing export still uses one safe text cell for the card name | ||
| expect(localFileDownload).toHaveBeenCalledWith('CompanyCards_policy123.csv', expect.stringContaining('\r\[email protected],"\'=SUM(1,2)",1234,'), expect.any(Function)); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Screen.Recording.2026-10-17.at.16.00.50.mov
@luacmartins I found an interesting case. Nov and Dec are periods that haven't happened yet, but they show up in the CSV as "periods with zero spend", when in reality there is no data for them yet.
Steps to reproduce:
Actual result: the CSV contains rows for Nov and Dec with 0 expenses and 0 total:
Expected result: I think future periods shouldn't appear. They haven't happened yet, so a 0 row reads as "no spend" instead of "no data yet".
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure. It's possible for users to create expenses in future dates. @JmillsExpensify what's your take here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Future periods should appear, if they have future dated expenses. I'm not sure it's a bug for this PR really. It's more of a question of whether we should be showing an empty state in that case, versus plotting $0. Isn't that what this PR is doing though? #104337
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, that makes sense. So NAB for this PR