Skip to content

Commit 35100b2

Browse files
authored
csv export addition (stolostron#3643)
Signed-off-by: Randy Bruno Piverger <rbrunopi@redhat.com>
1 parent a9e2184 commit 35100b2

File tree

5 files changed

+139
-14
lines changed

5 files changed

+139
-14
lines changed

frontend/src/routes/Home/Overview/SavedSearchesCard.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { SavedSearch } from '../../../resources'
1111
import { SearchResultCountDocument } from '../Search/search-sdk/search-sdk'
1212
import SavedSearchesCard from './SavedSearchesCard'
1313

14-
jest.mock('../../../resources', () => ({
14+
jest.mock('../../../resources/userpreference', () => ({
1515
listResources: jest.fn(() => ({
1616
promise: Promise.resolve([
1717
{
@@ -202,7 +202,7 @@ describe('SavedSearchesCard', () => {
202202
await waitFor(() => expect(getByText('2')).toBeTruthy())
203203
})
204204

205-
test('Renders erro correctly when search is disabled', async () => {
205+
test('Renders error correctly when search is disabled', async () => {
206206
nockIgnoreApiPaths()
207207
const { getByText } = render(
208208
<RecoilRoot

frontend/src/routes/Infrastructure/Clusters/ManagedClusters/components/DownloadConfigurationDropdown.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { mockBadRequestStatus, nockGet, nockIgnoreApiPaths } from '../../../../.
66
import { DownloadConfigurationDropdown } from './DownloadConfigurationDropdown'
77
import { clickByText } from '../../../../../lib/test-util'
88

9-
jest.mock('../../../../../resources/utils', () => ({
9+
jest.mock('../../../../../resources/utils/utils', () => ({
1010
__esModule: true,
11-
...jest.requireActual('../../../../../resources/utils'),
11+
...jest.requireActual('../../../../../resources/utils/utils'),
1212
createDownloadFile: jest.fn(),
1313
}))
1414

frontend/src/ui-components/AcmTable/AcmManageColumn.tsx

+2-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
DragDrop,
1818
Droppable,
1919
Draggable,
20-
ToolbarItem,
2120
} from '@patternfly/react-core'
2221
import { IAcmTableColumn } from './AcmTable'
2322
import { useTranslation } from '../../lib/acm-i18next'
@@ -50,7 +49,7 @@ export function AcmManageColumn<T>({
5049
}
5150

5251
return (
53-
<ToolbarItem>
52+
<>
5453
<ManageColumnModal<T>
5554
{...{
5655
isModalOpen,
@@ -67,7 +66,7 @@ export function AcmManageColumn<T>({
6766
<Tooltip content={t('Manage columns')} enableFlip trigger="mouseenter" position="top" exitDelay={50}>
6867
<Button isInline variant="plain" onClick={toggleModal} icon={<ColumnsIcon />} aria-label="columns-management" />
6968
</Tooltip>
70-
</ToolbarItem>
69+
</>
7170
)
7271
}
7372

frontend/src/ui-components/AcmTable/AcmTable.test.tsx

+26
Original file line numberDiff line numberDiff line change
@@ -928,4 +928,30 @@ describe('AcmTable', () => {
928928
expect(container.querySelector('div .pf-c-dropdown__toggle')).toBeInTheDocument()
929929
userEvent.click(getByTestId('create'))
930930
})
931+
932+
test('renders with export button', () => {
933+
const { getByTestId, getByText, container } = render(<Table showExportButton />)
934+
expect(container.querySelector('#export-search-result')).toBeInTheDocument()
935+
userEvent.click(getByTestId('export-search-result'))
936+
expect(getByText('Export as CSV')).toBeInTheDocument()
937+
})
938+
939+
test('export button should produce a file for download', () => {
940+
window.URL.createObjectURL = jest.fn()
941+
window.URL.revokeObjectURL = jest.fn()
942+
const { getByTestId, getByText, container } = render(<Table showExportButton />)
943+
944+
const anchorMocked = { href: '', click: jest.fn(), download: 'table-values', style: { display: '' } } as any
945+
const createElementSpyOn = jest.spyOn(document, 'createElement').mockReturnValueOnce(anchorMocked)
946+
document.body.appendChild = jest.fn()
947+
document.createElement('a').dispatchEvent = jest.fn()
948+
949+
expect(container.querySelector('#export-search-result')).toBeInTheDocument()
950+
userEvent.click(getByTestId('export-search-result'))
951+
expect(getByText('Export as CSV')).toBeInTheDocument()
952+
userEvent.click(getByText('Export as CSV'))
953+
954+
expect(createElementSpyOn).toHaveBeenCalledWith('a')
955+
expect(anchorMocked.download).toContain('table-values')
956+
})
931957
})

frontend/src/ui-components/AcmTable/AcmTable.tsx

+107-7
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
ToolbarItem,
3333
TooltipProps,
3434
} from '@patternfly/react-core'
35-
import { FilterIcon } from '@patternfly/react-icons'
35+
import { ExportIcon, FilterIcon } from '@patternfly/react-icons'
3636
import CaretDownIcon from '@patternfly/react-icons/dist/js/icons/caret-down-icon'
3737
import {
3838
expandable,
@@ -71,12 +71,15 @@ import {
7171
} from 'react'
7272
import { AcmButton } from '../AcmButton/AcmButton'
7373
import { AcmEmptyState } from '../AcmEmptyState/AcmEmptyState'
74+
import { AcmToastContext } from '../AcmAlert/AcmToast'
7475
import { useTranslation } from '../../lib/acm-i18next'
7576
import { usePaginationTitles } from '../../lib/paginationStrings'
7677
import { filterLabelMargin, filterOption, filterOptionBadge } from './filterStyles'
7778
import { AcmManageColumn } from './AcmManageColumn'
7879
import { useNavigate, useLocation } from 'react-router-dom-v5-compat'
7980
import { ParsedQuery, parse, stringify } from 'query-string'
81+
import { IAlertContext } from '../AcmAlert/AcmAlert'
82+
import { createDownloadFile } from '../../resources/utils'
8083

8184
type SortFn<T> = (a: T, b: T) => number
8285
type CellFn<T> = (item: T) => ReactNode
@@ -98,6 +101,9 @@ export interface IAcmTableColumn<T> {
98101
/** cell content, either on field name of using cell function */
99102
cell: CellFn<T> | string
100103

104+
/** exported value as a string, supported export: CSV*/
105+
exportContent?: CellFn<T>
106+
101107
transforms?: ITransform[]
102108

103109
cellTransforms?: ITransform[]
@@ -480,6 +486,8 @@ export type AcmTableProps<T> = {
480486
showColumManagement?: boolean
481487
nonZeroCount?: boolean
482488
indeterminateCount?: boolean
489+
showExportButton?: boolean
490+
exportFilePrefix?: string
483491
}
484492

485493
export function AcmTable<T>(props: AcmTableProps<T>) {
@@ -501,6 +509,8 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
501509
showColumManagement,
502510
nonZeroCount,
503511
indeterminateCount,
512+
showExportButton,
513+
exportFilePrefix,
504514
} = props
505515

506516
const defaultSort = {
@@ -511,6 +521,7 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
511521
const initialSearch = props.initialSearch || ''
512522

513523
const { t } = useTranslation()
524+
const toastContext = useContext(AcmToastContext)
514525

515526
// State that can come from context or component state (perPage)
516527
const [statePerPage, stateSetPerPage] = useState(props.initialPerPage || DEFAULT_ITEMS_PER_PAGE)
@@ -816,6 +827,51 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
816827
}
817828
}, [page, actualPage, setPage])
818829

830+
const exportTable = useCallback(
831+
(toastContext: IAlertContext) => {
832+
toastContext.addAlert({
833+
title: t('Generating data. Download may take a moment to start.'),
834+
type: 'info',
835+
autoClose: true,
836+
})
837+
838+
const fileNamePrefix = exportFilePrefix ?? 'table-values'
839+
const headerString: string[] = []
840+
const csvExportCellArray: string[] = []
841+
842+
selectedSortedCols.forEach(({ header }) => {
843+
header && headerString.push(header)
844+
})
845+
csvExportCellArray.push(headerString.join(','))
846+
847+
sorted.forEach(({ item }) => {
848+
let contentString: string[] = []
849+
selectedSortedCols.forEach(({ header, exportContent }) => {
850+
if (header) {
851+
// if callback and its output exists, add to array, else add "-"
852+
exportContent && exportContent(item)
853+
? contentString.push(exportContent(item) as string)
854+
: contentString.push('-')
855+
}
856+
})
857+
contentString = [contentString.join(',')]
858+
contentString[0] && csvExportCellArray.push(contentString[0])
859+
})
860+
861+
const exportString = csvExportCellArray.join('\n')
862+
const fileName = `${fileNamePrefix}-${Date.now()}.csv`
863+
864+
createDownloadFile(fileName, exportString, 'text/csv')
865+
866+
toastContext.addAlert({
867+
title: t('Export successful'),
868+
type: 'success',
869+
autoClose: true,
870+
})
871+
},
872+
[selectedSortedCols, sorted, exportFilePrefix, t]
873+
)
874+
819875
const paged = useMemo<ITableItem<T>[]>(() => {
820876
const start = (actualPage - 1) * perPage
821877
return sorted.slice(start, start + perPage)
@@ -1053,6 +1109,7 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
10531109
const hasItems = (items && items.length > 0 && filtered) || nonZeroCount || indeterminateCount
10541110
const showToolbar = props.showToolbar !== false ? hasItems : false
10551111
const topToolbarStyle = items ? {} : { paddingBottom: 0 }
1112+
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false)
10561113

10571114
const translatedPaginationTitles = usePaginationTitles()
10581115

@@ -1158,12 +1215,55 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
11581215
<TableActions actions={tableActions} selections={selected} items={items} keyFn={keyFn} />
11591216
)}
11601217
{customTableAction && <ToolbarItem>{customTableAction}</ToolbarItem>}
1161-
{showColumManagement && (
1162-
<AcmManageColumn<T>
1163-
{...{ selectedColIds, setSelectedColIds, requiredColIds, defaultColIds, setColOrderIds, colOrderIds }}
1164-
allCols={columns.filter((col) => !col.isActionCol)}
1165-
/>
1166-
)}
1218+
<ToolbarGroup spaceItems={{ default: 'spaceItemsNone' }}>
1219+
{showColumManagement && (
1220+
<ToolbarItem>
1221+
<AcmManageColumn<T>
1222+
{...{
1223+
selectedColIds,
1224+
setSelectedColIds,
1225+
requiredColIds,
1226+
defaultColIds,
1227+
setColOrderIds,
1228+
colOrderIds,
1229+
}}
1230+
allCols={columns.filter((col) => !col.isActionCol)}
1231+
/>
1232+
</ToolbarItem>
1233+
)}
1234+
{showExportButton && (
1235+
<ToolbarItem key={`export-toolbar-item`}>
1236+
<Dropdown
1237+
onSelect={(event) => {
1238+
event?.stopPropagation()
1239+
setIsExportMenuOpen(false)
1240+
}}
1241+
className="export-dropdownMenu"
1242+
toggle={
1243+
<DropdownToggle
1244+
toggleIndicator={null}
1245+
onToggle={(value, event) => {
1246+
event.stopPropagation()
1247+
setIsExportMenuOpen(value)
1248+
}}
1249+
aria-label="export-search-result"
1250+
id="export-search-result"
1251+
>
1252+
<ExportIcon />
1253+
</DropdownToggle>
1254+
}
1255+
isOpen={isExportMenuOpen}
1256+
isPlain
1257+
dropdownItems={[
1258+
<DropdownItem key="export-csv" onClick={() => exportTable(toastContext)}>
1259+
{t('Export as CSV')}
1260+
</DropdownItem>,
1261+
]}
1262+
position={'left'}
1263+
/>
1264+
</ToolbarItem>
1265+
)}
1266+
</ToolbarGroup>
11671267
{additionalToolbarItems}
11681268
{(!props.autoHidePagination || filtered.length > perPage) && (
11691269
<ToolbarItem variant="pagination">

0 commit comments

Comments
 (0)