Skip to content

Commit

Permalink
csv export addition
Browse files Browse the repository at this point in the history
Signed-off-by: Randy Bruno Piverger <[email protected]>
  • Loading branch information
Randy424 committed Jul 12, 2024
1 parent a9e2184 commit 87fb2f3
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 13 deletions.
6 changes: 5 additions & 1 deletion frontend/src/routes/Home/Overview/SavedSearchesCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ const errorMock = [
]

describe('SavedSearchesCard', () => {
afterEach(() => {
jest.clearAllMocks()
})

test('Renders valid SavedSearchesCard with no saved searches', async () => {
const { getByText } = render(
<RecoilRoot
Expand Down Expand Up @@ -202,7 +206,7 @@ describe('SavedSearchesCard', () => {
await waitFor(() => expect(getByText('2')).toBeTruthy())
})

test('Renders erro correctly when search is disabled', async () => {
test('Renders error correctly when search is disabled', async () => {
nockIgnoreApiPaths()
const { getByText } = render(
<RecoilRoot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { mockBadRequestStatus, nockGet, nockIgnoreApiPaths } from '../../../../.
import { DownloadConfigurationDropdown } from './DownloadConfigurationDropdown'
import { clickByText } from '../../../../../lib/test-util'

jest.mock('../../../../../resources/utils', () => ({
jest.mock('../../../../../resources/utils/utils', () => ({
__esModule: true,
...jest.requireActual('../../../../../resources/utils'),
...jest.requireActual('../../../../../resources/utils/utils'),
createDownloadFile: jest.fn(),
}))

Expand Down
5 changes: 2 additions & 3 deletions frontend/src/ui-components/AcmTable/AcmManageColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
DragDrop,
Droppable,
Draggable,
ToolbarItem,
} from '@patternfly/react-core'
import { IAcmTableColumn } from './AcmTable'
import { useTranslation } from '../../lib/acm-i18next'
Expand Down Expand Up @@ -50,7 +49,7 @@ export function AcmManageColumn<T>({
}

return (
<ToolbarItem>
<>
<ManageColumnModal<T>
{...{
isModalOpen,
Expand All @@ -67,7 +66,7 @@ export function AcmManageColumn<T>({
<Tooltip content={t('Manage columns')} enableFlip trigger="mouseenter" position="top" exitDelay={50}>
<Button isInline variant="plain" onClick={toggleModal} icon={<ColumnsIcon />} aria-label="columns-management" />
</Tooltip>
</ToolbarItem>
</>
)
}

Expand Down
26 changes: 26 additions & 0 deletions frontend/src/ui-components/AcmTable/AcmTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -928,4 +928,30 @@ describe('AcmTable', () => {
expect(container.querySelector('div .pf-c-dropdown__toggle')).toBeInTheDocument()
userEvent.click(getByTestId('create'))
})

test('renders with export button', () => {
const { getByTestId, getByText, container } = render(<Table showExportButton />)
expect(container.querySelector('#export-search-result')).toBeInTheDocument()
userEvent.click(getByTestId('export-search-result'))
expect(getByText('Export as CSV')).toBeInTheDocument()
})

test('export button should produce a file for download', () => {
window.URL.createObjectURL = jest.fn()
window.URL.revokeObjectURL = jest.fn()
const { getByTestId, getByText, container } = render(<Table showExportButton />)

const anchorMocked = { href: '', click: jest.fn(), download: 'table-values', style: { display: '' } } as any
const createElementSpyOn = jest.spyOn(document, 'createElement').mockReturnValueOnce(anchorMocked)
document.body.appendChild = jest.fn()
document.createElement('a').dispatchEvent = jest.fn()

expect(container.querySelector('#export-search-result')).toBeInTheDocument()
userEvent.click(getByTestId('export-search-result'))
expect(getByText('Export as CSV')).toBeInTheDocument()
userEvent.click(getByText('Export as CSV'))

expect(createElementSpyOn).toHaveBeenCalledWith('a')
expect(anchorMocked.download).toContain('table-values')
})
})
114 changes: 107 additions & 7 deletions frontend/src/ui-components/AcmTable/AcmTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
ToolbarItem,
TooltipProps,
} from '@patternfly/react-core'
import { FilterIcon } from '@patternfly/react-icons'
import { ExportIcon, FilterIcon } from '@patternfly/react-icons'
import CaretDownIcon from '@patternfly/react-icons/dist/js/icons/caret-down-icon'
import {
expandable,
Expand Down Expand Up @@ -71,12 +71,15 @@ import {
} from 'react'
import { AcmButton } from '../AcmButton/AcmButton'
import { AcmEmptyState } from '../AcmEmptyState/AcmEmptyState'
import { AcmToastContext } from '../AcmAlert/AcmToast'
import { useTranslation } from '../../lib/acm-i18next'
import { usePaginationTitles } from '../../lib/paginationStrings'
import { filterLabelMargin, filterOption, filterOptionBadge } from './filterStyles'
import { AcmManageColumn } from './AcmManageColumn'
import { useNavigate, useLocation } from 'react-router-dom-v5-compat'
import { ParsedQuery, parse, stringify } from 'query-string'
import { IAlertContext } from '../AcmAlert/AcmAlert'
import { createDownloadFile } from '../../resources/utils'

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

/** exported value as a string, supported export: CSV*/
exportContent?: CellFn<T>

transforms?: ITransform[]

cellTransforms?: ITransform[]
Expand Down Expand Up @@ -480,6 +486,8 @@ export type AcmTableProps<T> = {
showColumManagement?: boolean
nonZeroCount?: boolean
indeterminateCount?: boolean
showExportButton?: boolean
exportFilePrefix?: string
}

export function AcmTable<T>(props: AcmTableProps<T>) {
Expand All @@ -501,6 +509,8 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
showColumManagement,
nonZeroCount,
indeterminateCount,
showExportButton,
exportFilePrefix,
} = props

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

const { t } = useTranslation()
const toastContext = useContext(AcmToastContext)

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

const exportTable = useCallback(
(toastContext: IAlertContext) => {
toastContext.addAlert({
title: t('Generating data. Download may take a moment to start.'),
type: 'info',
autoClose: true,
})

const fileNamePrefix = exportFilePrefix ?? 'table-values'
const headerString: string[] = []
const csvExportCellArray: string[] = []

selectedSortedCols.forEach(({ header }) => {
header && headerString.push(header)
})
csvExportCellArray.push(headerString.join(','))

sorted.forEach(({ item }) => {
let contentString: string[] = []
selectedSortedCols.forEach(({ header, exportContent }) => {
if (header) {
// if callback and its output exists, add to array, else add "-"
exportContent && exportContent(item)
? contentString.push(exportContent(item) as string)
: contentString.push('-')
}
})
contentString = [contentString.join(',')]
contentString[0] && csvExportCellArray.push(contentString[0])
})

const exportString = csvExportCellArray.join('\n')
const fileName = `${fileNamePrefix}-${Date.now()}.csv`

createDownloadFile(fileName, exportString, 'text/csv')

toastContext.addAlert({
title: t('Export successful'),
type: 'success',
autoClose: true,
})
},
[selectedSortedCols, sorted, exportFilePrefix, t]
)

const paged = useMemo<ITableItem<T>[]>(() => {
const start = (actualPage - 1) * perPage
return sorted.slice(start, start + perPage)
Expand Down Expand Up @@ -1053,6 +1109,7 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
const hasItems = (items && items.length > 0 && filtered) || nonZeroCount || indeterminateCount
const showToolbar = props.showToolbar !== false ? hasItems : false
const topToolbarStyle = items ? {} : { paddingBottom: 0 }
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false)

const translatedPaginationTitles = usePaginationTitles()

Expand Down Expand Up @@ -1158,12 +1215,55 @@ export function AcmTable<T>(props: AcmTableProps<T>) {
<TableActions actions={tableActions} selections={selected} items={items} keyFn={keyFn} />
)}
{customTableAction && <ToolbarItem>{customTableAction}</ToolbarItem>}
{showColumManagement && (
<AcmManageColumn<T>
{...{ selectedColIds, setSelectedColIds, requiredColIds, defaultColIds, setColOrderIds, colOrderIds }}
allCols={columns.filter((col) => !col.isActionCol)}
/>
)}
<ToolbarGroup spaceItems={{ default: 'spaceItemsNone' }}>
{showColumManagement && (
<ToolbarItem>
<AcmManageColumn<T>
{...{
selectedColIds,
setSelectedColIds,
requiredColIds,
defaultColIds,
setColOrderIds,
colOrderIds,
}}
allCols={columns.filter((col) => !col.isActionCol)}
/>
</ToolbarItem>
)}
{showExportButton && (
<ToolbarItem key={`export-toolbar-item`}>
<Dropdown
onSelect={(event) => {
event?.stopPropagation()
setIsExportMenuOpen(false)
}}
className="export-dropdownMenu"
toggle={
<DropdownToggle
toggleIndicator={null}
onToggle={(value, event) => {
event.stopPropagation()
setIsExportMenuOpen(value)
}}
aria-label="export-search-result"
id="export-search-result"
>
<ExportIcon />
</DropdownToggle>
}
isOpen={isExportMenuOpen}
isPlain
dropdownItems={[
<DropdownItem key="export-csv" onClick={() => exportTable(toastContext)}>
{t('Export as CSV')}
</DropdownItem>,
]}
position={'left'}
/>
</ToolbarItem>
)}
</ToolbarGroup>
{additionalToolbarItems}
{(!props.autoHidePagination || filtered.length > perPage) && (
<ToolbarItem variant="pagination">
Expand Down

0 comments on commit 87fb2f3

Please sign in to comment.