Skip to content
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

AcmTable csv export button addition #3643

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/routes/Home/Overview/SavedSearchesCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SavedSearch } from '../../../resources'
import { SearchResultCountDocument } from '../Search/search-sdk/search-sdk'
import SavedSearchesCard from './SavedSearchesCard'

jest.mock('../../../resources', () => ({
jest.mock('../../../resources/userpreference', () => ({
listResources: jest.fn(() => ({
promise: Promise.resolve([
{
Expand Down Expand Up @@ -202,7 +202,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