/** * External dependencies */ import { CheckboxControl, Button } from '@wordpress/components'; import { applyFilters } from '@wordpress/hooks'; import { Fragment, useRef, useState } from '@wordpress/element'; import { compose } from '@wordpress/compose'; import { focus } from '@wordpress/dom'; import { withDispatch } from '@wordpress/data'; import { get, noop, partial, uniq } from 'lodash'; import { __ } from '@wordpress/i18n'; import classnames from 'classnames'; import PropTypes from 'prop-types'; /** * WooCommerce dependencies */ import { CompareButton, Search, TableCard } from '@woocommerce/components'; import DownloadIcon from './download-icon'; import { getIdsFromQuery, getSearchWords, onQueryChange, updateQueryString, } from '@woocommerce/navigation'; import { downloadCSVFile, generateCSVDataFromTable, generateCSVFileName, } from '@woocommerce/csv-export'; import { SETTINGS_STORE_NAME, useUserPreferences } from '@woocommerce/data'; /** * Internal dependencies */ import ReportError from 'analytics/components/report-error'; import { getReportChartData, getReportTableData } from 'wc-api/reports/utils'; import { QUERY_DEFAULTS } from 'wc-api/constants'; import withSelect from 'wc-api/with-select'; import { extendTableData } from './utils'; import { recordEvent } from 'lib/tracks'; import './style.scss'; const TABLE_FILTER = 'woocommerce_admin_report_table'; const ReportTable = ( props ) => { const { getHeadersContent, getRowsContent, getSummary, isRequesting, primaryData, tableData, endpoint, // These props are not used in the render function, but are destructured // so they are not included in the `tableProps` variable. // eslint-disable-next-line no-unused-vars itemIdField, // eslint-disable-next-line no-unused-vars tableQuery, compareBy, searchBy, labels = {}, ...tableProps } = props; // Pull these props out separately because they need to be included in tableProps. const { query, columnPrefsKey } = props; const { items, query: reportQuery } = tableData; const initialSelectedRows = query.filter ? getIdsFromQuery( query[ compareBy ] ) : []; const [ selectedRows, setSelectedRows ] = useState( initialSelectedRows ); const scrollPointRef = useRef( null ); const { updateUserPreferences, ...userData } = useUserPreferences(); // Bail early if we've encountered an error. const isError = tableData.isError || primaryData.isError; if ( isError ) { return ; } let userPrefColumns = []; if ( columnPrefsKey ) { userPrefColumns = userData && userData[ columnPrefsKey ] ? userData[ columnPrefsKey ] : userPrefColumns; } const onColumnsChange = ( shownColumns, toggledColumn ) => { const columns = getHeadersContent().map( ( header ) => header.key ); const hiddenColumns = columns.filter( ( column ) => ! shownColumns.includes( column ) ); if ( columnPrefsKey ) { const userDataFields = { [ columnPrefsKey ]: hiddenColumns, }; updateUserPreferences( userDataFields ); } if ( toggledColumn ) { const eventProps = { report: endpoint, column: toggledColumn, status: shownColumns.includes( toggledColumn ) ? 'on' : 'off', }; recordEvent( 'analytics_table_header_toggle', eventProps ); } }; const onPageChange = ( newPage, source ) => { scrollPointRef.current.scrollIntoView(); const tableElement = scrollPointRef.current.nextSibling.querySelector( '.woocommerce-table__table' ); const focusableElements = focus.focusable.find( tableElement ); if ( focusableElements.length ) { focusableElements[ 0 ].focus(); } if ( source ) { if ( source === 'goto' ) { recordEvent( 'analytics_table_go_to_page', { report: endpoint, page: newPage, } ); } else { recordEvent( 'analytics_table_page_click', { report: endpoint, direction: source, } ); } } }; const trackTableSearch = () => { // @todo: decide if this should only fire for new tokens (not any/all changes). recordEvent( 'analytics_table_filter', { report: endpoint } ); }; const onSort = ( key, direction ) => { onQueryChange( 'sort' )( key, direction ); const eventProps = { report: endpoint, column: key, direction, }; recordEvent( 'analytics_table_sort', eventProps ); }; const filterShownHeaders = ( headers, hiddenKeys ) => { // If no user preferences, set visibilty based on column default. if ( ! hiddenKeys ) { return headers.map( ( header ) => ( { ...header, visible: header.required || ! header.hiddenByDefault, } ) ); } // Set visibilty based on user preferences. return headers.map( ( header ) => ( { ...header, visible: header.required || ! hiddenKeys.includes( header.key ), } ) ); }; const onClickDownload = () => { const { initiateReportExport, title } = props; const params = Object.assign( {}, query ); const { data, totalResults } = items; let downloadType = 'browser'; // Delete unnecessary items from filename. delete params.extended_info; if ( params.search ) { delete params[ searchBy ]; } if ( data && data.length === totalResults ) { downloadCSVFile( generateCSVFileName( title, params ), generateCSVDataFromTable( getHeadersContent(), getRowsContent( data ) ) ); } else { downloadType = 'email'; initiateReportExport( endpoint, title, reportQuery ); } recordEvent( 'analytics_table_download', { report: endpoint, rows: totalResults, downloadType, } ); }; const onCompare = () => { const { compareParam } = props; if ( compareBy ) { onQueryChange( 'compare' )( compareBy, compareParam, selectedRows.join( ',' ) ); } }; const onSearchChange = ( values ) => { const { baseSearchQuery, compareParam } = props; // A comma is used as a separator between search terms, so we want to escape // any comma they contain. const searchTerms = values.map( ( v ) => v.label.replace( ',', '%2C' ) ); if ( searchTerms.length ) { updateQueryString( { filter: undefined, [ compareParam ]: undefined, [ searchBy ]: undefined, ...baseSearchQuery, search: uniq( searchTerms ).join( ',' ), } ); } else { updateQueryString( { search: undefined, } ); } trackTableSearch(); }; const selectAllRows = ( checked ) => { const { ids } = props; setSelectedRows( checked ? ids : [] ); }; const selectRow = ( i, checked ) => { const { ids } = props; if ( checked ) { setSelectedRows( uniq( [ ids[ i ], ...selectedRows ] ) ); } else { const index = selectedRows.indexOf( ids[ i ] ); setSelectedRows( [ ...selectedRows.slice( 0, index ), ...selectedRows.slice( index + 1 ), ] ); } }; const getCheckbox = ( i ) => { const { ids = [] } = props; const isChecked = selectedRows.indexOf( ids[ i ] ) !== -1; return { display: ( ), value: false, }; }; const getAllCheckbox = () => { const { ids = [] } = props; const hasData = ids.length > 0; const isAllChecked = hasData && ids.length === selectedRows.length; return { cellClassName: 'is-checkbox-column', key: 'compare', label: ( ), required: true, }; }; const isLoading = isRequesting || tableData.isRequesting || primaryData.isRequesting; const totals = get( primaryData, [ 'data', 'totals' ], {} ); const totalResults = items.totalResults; const downloadable = totalResults > 0; // Search words are in the query string, not the table query. const searchWords = getSearchWords( query ); const searchedLabels = searchWords.map( ( v ) => ( { key: v, label: v, } ) ); /** * Filter report table. * * Enables manipulation of data used to create a report table. * * @param {Object} reportTableData - data used to create the table. * @param {string} reportTableData.endpoint - table api endpoint. * @param {Array} reportTableData.headers - table headers data. * @param {Array} reportTableData.rows - table rows data. * @param {Object} reportTableData.totals - total aggregates for request. * @param {Array} reportTableData.summary - summary numbers data. * @param {Object} reportTableData.items - response from api requerst. */ const filteredTableProps = applyFilters( TABLE_FILTER, { endpoint, headers: getHeadersContent(), rows: getRowsContent( items.data ), totals, summary: getSummary ? getSummary( totals, totalResults ) : null, items, } ); let { headers, rows } = filteredTableProps; const { summary } = filteredTableProps; // Add in selection for comparisons. if ( compareBy ) { rows = rows.map( ( row, i ) => { return [ getCheckbox( i ), ...row ]; } ); headers = [ getAllCheckbox(), ...headers ]; } // Hide any headers based on user prefs, if loaded. const filteredHeaders = filterShownHeaders( headers, userPrefColumns ); const className = classnames( 'woocommerce-report-table', { 'has-compare': !! compareBy, 'has-search': !! searchBy, } ); return (
{ labels.compareButton || __( 'Compare', 'woocommerce-admin' ) } ), searchBy && ( ), downloadable && ( ), ] } headers={ filteredHeaders } isLoading={ isLoading } onQueryChange={ onQueryChange } onColumnsChange={ onColumnsChange } onSort={ onSort } onPageChange={ onPageChange } rows={ rows } rowsPerPage={ parseInt( reportQuery.per_page, 10 ) || QUERY_DEFAULTS.pageSize } summary={ summary } totalRows={ totalResults } { ...tableProps } /> ); }; ReportTable.propTypes = { /** * Pass in query parameters to be included in the path when onSearch creates a new url. */ baseSearchQuery: PropTypes.object, /** * The string to use as a query parameter when comparing row items. */ compareBy: PropTypes.string, /** * Url query parameter compare function operates on */ compareParam: PropTypes.string, /** * The key for user preferences settings for column visibility. */ columnPrefsKey: PropTypes.string, /** * The endpoint to use in API calls to populate the table rows and summary. * For example, if `taxes` is provided, data will be fetched from the report * `taxes` endpoint (ie: `/wc-analytics/reports/taxes` and `/wc/v4/reports/taxes/stats`). * If the provided endpoint doesn't exist, an error will be shown to the user * with `ReportError`. */ endpoint: PropTypes.string, /** * Name of the methods available via `select( 'wc-api' )` that will be used to * load more data for table items. If omitted, no call will be made and only * the data returned by the reports endpoint will be used. */ extendItemsMethodNames: PropTypes.shape( { getError: PropTypes.string, isRequesting: PropTypes.string, load: PropTypes.string, } ), /** * A function that returns the headers object to build the table. */ getHeadersContent: PropTypes.func.isRequired, /** * A function that returns the rows array to build the table. */ getRowsContent: PropTypes.func.isRequired, /** * A function that returns the summary object to build the table. */ getSummary: PropTypes.func, /** * The name of the property in the item object which contains the id. */ itemIdField: PropTypes.string, /** * Custom labels for table header actions. */ labels: PropTypes.shape( { compareButton: PropTypes.string, downloadButton: PropTypes.string, helpText: PropTypes.string, placeholder: PropTypes.string, } ), /** * Primary data of that report. If it's not provided, it will be automatically * loaded via the provided `endpoint`. */ primaryData: PropTypes.object, /** * The string to use as a query parameter when searching row items. */ searchBy: PropTypes.string, /** * List of fields used for summary numbers. (Reduces queries) */ summaryFields: PropTypes.arrayOf( PropTypes.string ), /** * Table data of that report. If it's not provided, it will be automatically * loaded via the provided `endpoint`. */ tableData: PropTypes.object.isRequired, /** * Properties to be added to the query sent to the report table endpoint. */ tableQuery: PropTypes.object, /** * String to display as the title of the table. */ title: PropTypes.string.isRequired, }; ReportTable.defaultProps = { primaryData: {}, tableData: { items: { data: [], totalResults: 0, }, query: {}, }, tableQuery: {}, compareParam: 'filter', downloadable: false, onSearch: noop, baseSearchQuery: {}, }; export default compose( withSelect( ( select, props ) => { const { endpoint, getSummary, isRequesting, itemIdField, query, tableData, tableQuery, filters, advancedFilters, summaryFields, } = props; if ( isRequesting || ( query.search && ! ( query[ endpoint ] && query[ endpoint ].length ) ) ) { return {}; } const { woocommerce_default_date_range: defaultDateRange } = select( SETTINGS_STORE_NAME ).getSetting( 'wc_admin', 'wcAdminSettings' ); // Variations and Category charts are powered by the /reports/products/stats endpoint. const chartEndpoint = [ 'variations', 'categories' ].includes( endpoint ) ? 'products' : endpoint; const primaryData = getSummary ? getReportChartData( { endpoint: chartEndpoint, dataType: 'primary', query, select, filters, advancedFilters, tableQuery, defaultDateRange, fields: summaryFields, } ) : {}; const queriedTableData = tableData || getReportTableData( { endpoint, query, select, tableQuery, filters, advancedFilters, defaultDateRange, } ); const extendedTableData = extendTableData( select, props, queriedTableData ); return { primaryData, ids: itemIdField ? extendedTableData.items.data.map( ( item ) => item[ itemIdField ] ) : [], tableData: extendedTableData, query: { ...tableQuery, ...query }, }; } ), withDispatch( ( dispatch ) => { const { initiateReportExport } = dispatch( 'wc-api' ); return { initiateReportExport, }; } ) )( ReportTable );