From 6845cca15c851413542a051815fd89faff47b77a Mon Sep 17 00:00:00 2001 From: Helmy Giacoman Date: Tue, 28 May 2024 11:43:44 +0200 Subject: [PATCH] [CPCN-177] - Add `Source` column to Content Activity report (#904) * Minor code clean up CPCN-177 * Add `Source` column to Content Activity report This adds the column `source` to the content activity report both in the frontend UI and in the CSV export. CPCN-177 * Fix format with black CPCN-177 * Implement typings for props and remove `_.get` usage CPCN-177 * Extract report filter into separate component The intention is to simplify and to improve the `ContentActivity.tsx` component from using UNSAFE cycle methods. CPCN-177 * Remove unnecessary state of `ContentActivity.tsx` CPCN-177 * Small UI/UX improvements CPCN-177 --- assets/company-reports/actions.ts | 11 +- .../components/CompanyReportsApp.tsx | 19 +- .../components/ContentActivity.tsx | 345 +++++------------- .../components/ContentActivityFilters.tsx | 137 +++++++ assets/components/MultiSelectDropdown.tsx | 1 - newsroom/reports/content_activity.py | 3 + newsroom/templates/company_reports.html | 1 - 7 files changed, 253 insertions(+), 264 deletions(-) create mode 100644 assets/company-reports/components/ContentActivityFilters.tsx diff --git a/assets/company-reports/actions.ts b/assets/company-reports/actions.ts index 585f74149..1e12b555e 100644 --- a/assets/company-reports/actions.ts +++ b/assets/company-reports/actions.ts @@ -119,7 +119,7 @@ export function runReport() { }; } -export function fetchAggregations(url: any) { +export function fetchAggregations(url: string) { return function(dispatch: any, getState: any) { const queryString = getReportQueryString(getState(), 0, false, notify); @@ -144,7 +144,6 @@ export function fetchReport(url: any, next?: any, exportReport?: any) { } const queryString = getReportQueryString(getState(), next, exportReport, notify); - let apiRequest: any; if (exportReport) { if (getState().results.length <= 0) { @@ -156,18 +155,14 @@ export function fetchReport(url: any, next?: any, exportReport?: any) { return; } - if (queryString) { - apiRequest = server.get(`${url}?${queryString}`); - } else { - apiRequest = server.get(url); - } + const apiRequest = server.get(queryString ? `${url}?${queryString}` : url); return apiRequest.then((data: any) => { if (!next) { dispatch(receivedData(data)); } else { dispatch(isLoading(false)); - dispatch(addResults(get(data, 'results', []))); + dispatch(addResults(data?.results ?? [])); } }) .catch((error: any) => errorHandler(error, dispatch, setError)); diff --git a/assets/company-reports/components/CompanyReportsApp.tsx b/assets/company-reports/components/CompanyReportsApp.tsx index 467643b43..29e200346 100644 --- a/assets/company-reports/components/CompanyReportsApp.tsx +++ b/assets/company-reports/components/CompanyReportsApp.tsx @@ -12,7 +12,7 @@ import {gettext} from 'utils'; import {panels} from '../utils'; const options = [ - {value: '', text: ''}, + {value: '', text: gettext('Select a report')}, {value: REPORTS_NAMES.COMPANY_SAVED_SEARCHES, text: gettext('Saved searches per company')}, {value: REPORTS_NAMES.USER_SAVED_SEARCHES, text: gettext('Saved searches per user')}, {value: REPORTS_NAMES.COMPANY_PRODUCTS, text: gettext('Products per company')}, @@ -27,16 +27,11 @@ const options = [ class CompanyReportsApp extends React.Component { static propTypes: any; - constructor(props: any, context: any) { - super(props, context); - this.getPanel = this.getPanel.bind(this); - } - - getPanel() { + getPanel = () => { const Panel = panels[this.props.activeReport]; return Panel && this.props.results && ; - } + }; render() { const reportOptions = !this.props.apiEnabled ? options : @@ -54,8 +49,14 @@ class CompanyReportsApp extends React.Component { id={'company-reports'} name={'company-reports'} value={this.props.activeReport || ''} + onChange={(event: any) => this.props.setActiveReport(event.target.value)}> - {reportOptions.map((option: any) => )} + {reportOptions + .map((option: any) => + + )} diff --git a/assets/company-reports/components/ContentActivity.tsx b/assets/company-reports/components/ContentActivity.tsx index cf8fd9128..196aa446e 100644 --- a/assets/company-reports/components/ContentActivity.tsx +++ b/assets/company-reports/components/ContentActivity.tsx @@ -1,105 +1,55 @@ import React, {Fragment} from 'react'; -import PropTypes from 'prop-types'; -import moment from 'moment'; import {connect} from 'react-redux'; -import {get, keyBy, cloneDeep} from 'lodash'; import {gettext, formatTime} from '../../utils'; -import {fetchReport, REPORTS, runReport, toggleFilter, fetchAggregations} from '../actions'; +import {fetchReport, REPORTS} from '../actions'; -import CalendarButton from '../../components/CalendarButton'; -import MultiSelectDropdown from '../../components/MultiSelectDropdown'; import ReportsTable from './ReportsTable'; +import {ContentActivityFilters} from './ContentActivityFilters'; + + +interface IReportAction { + download?: number; + copy?: number; + share?: number; + print?: number; + open?: number; + preview?: number; + clipboard?: number; + api?: number; +} -class ContentActivity extends React.Component { - static propTypes: any; - constructor(props: any) { - super(props); - - this.onDateChange = this.onDateChange.bind(this); - this.exportToCSV = this.exportToCSV.bind(this); - this.onChangeSection = this.onChangeSection.bind(this); - - this.state = { - filters: ['section', 'genre', 'company', 'action'], - results: [], - section: { - field: 'section', - label: gettext('Section'), - options: this.props.sections - .filter((section: any) => section.group === 'wire' || (section.group === 'api' && this.props.apiEnabled) - || section.group === 'monitoring') - .map((section: any) => ({ - label: section.name, - value: section.name, - })), - onChange: this.onChangeSection, - showAllButton: false, - multi: false, - default: this.props.sections[0].name, - }, - genre: { - field: 'genre', - label: gettext('Genres'), - options: [], - onChange: this.props.toggleFilter, - showAllButton: true, - multi: true, - default: [], - }, - company: { - field: 'company', - label: gettext('Companies'), - options: [], - onChange: this.props.toggleFilter, - showAllButton: true, - multi: false, - default: null, - }, - action: { - field: 'action', - label: gettext('Actions'), - options: [ - {label: gettext('Download'), value: 'download'}, - {label: gettext('Copy'), value: 'copy'}, - {label: gettext('Share'), value: 'share'}, - {label: gettext('Print'), value: 'print'}, - {label: gettext('Open'), value: 'open'}, - {label: gettext('Preview'), value: 'preview'}, - {label: gettext('Clipboard'), value: 'clipboard'}, - {label: gettext('API retrieval'), value: 'api'}, - ], - onChange: this.props.toggleFilter, - showAllButton: true, - multi: true, - default: [], - } - }; - } - - componentWillMount() { - // Fetch the genre & company aggregations to populate those dropdowns - this.props.fetchAggregations(REPORTS['content-activity']); - } +interface IResultItem { + _id: string; + versioncreated: string; + headline: string; + anpa_take_key: string; + source: string; + place: Array<{name: string}>; + service: Array<{name: string}>; + subject: Array<{name: string}>; + aggs?: { + actions?: IReportAction; + total?: number; + companies?: Array; + }; +} - componentWillReceiveProps(nextProps: any) { - if (this.props.results !== nextProps.results) { - this.updateResults(nextProps); - } +interface IProps { + results: Array, + print: boolean; + companies: Array; - if (this.props.aggregations !== nextProps.aggregations) { - this.updateAggregations(nextProps); - } - } + fetchReport: typeof fetchReport; + reportParams: Dictionary; +} +class ContentActivity extends React.Component { getFilteredActions() { - let actions = get(this.props, 'reportParams.action'); - - if (!get(actions, 'length', 0)) { - actions = ['download', 'copy', 'share', 'print', 'open', 'preview', 'clipboard', 'api']; - } + const {reportParams} = this.props; + const defaultActions = ['download', 'copy', 'share', 'print', 'open', 'preview', 'clipboard', 'api']; - return actions; + return reportParams?.action || defaultActions; } getHeaders() { @@ -110,6 +60,7 @@ class ContentActivity extends React.Component { gettext('Place'), gettext('Category'), gettext('Subject'), + gettext('Source'), gettext('Companies'), gettext('Actions'), ]; @@ -127,125 +78,67 @@ class ContentActivity extends React.Component { return headers; } - updateAggregations(props: any) { - const genre = cloneDeep(this.state.genre); - const company = cloneDeep(this.state.company); - - genre.options = props.aggregations.genres - .sort() - .map((genre: any) => ({ - label: genre, - value: genre, - })); - company.options = props.companies - .filter((company: any) => props.aggregations.companies.includes(company._id)) - .map((company: any) => ({ - label: company.name, - value: company.name, - })); - - this.setState({ - genre, - company, - }); - } - - updateResults(props: any) { - const companies = keyBy(this.props.companies, '_id'); - - const results = props.results.map( - (item: any) => ({ - _id: item._id, - versioncreated: formatTime(get(item, 'versioncreated') || ''), - headline: get(item, 'headline') || '', - anpa_take_key: get(item, 'anpa_take_key') || '', - place: (get(item, 'place') || []) - .map((place: any) => place.name) - .sort(), - service: (get(item, 'service') || []) - .map((service: any) => service.name) - .sort(), - subject: (get(item, 'subject') || []) - .map((subject: any) => subject.name) - .sort(), - total: get(item, 'aggs.total') || 0, - companies: (get(item, 'aggs.companies') || []) - .map((company: any) => companies[company].name) - .sort(), - actions: { - download: get(item, 'aggs.actions.download') || 0, - copy: get(item, 'aggs.actions.copy') || 0, - share: get(item, 'aggs.actions.share') || 0, - print: get(item, 'aggs.actions.print') || 0, - open: get(item, 'aggs.actions.open') || 0, - preview: get(item, 'aggs.actions.preview') || 0, - clipboard: get(item, 'aggs.actions.clipboard') || 0, - api: get(item, 'aggs.actions.api') || 0, - } - }) - ); - this.setState({results}); - } - - onDateChange(value: any) { - this.props.toggleFilter('date_from', value); - this.props.fetchAggregations(REPORTS['content-activity']); - } - - onChangeSection(field: any, value: any) { - this.props.toggleFilter('section', value); - this.props.fetchAggregations(REPORTS['content-activity']); - } - - exportToCSV() { + exportToCSV = () => { this.props.fetchReport( REPORTS['content-activity'], false, true ); - } + }; + + renderSorted = (elems: Array) => { + return elems + .sort() + .map((name) => ( + + {name}
+
+ )); + }; + + renderNamesSorted = (elems: Array<{name: string}>) => { + return this.renderSorted((elems || []).map((x) => x.name)); + }; + + renderCompaniesSorted = (companiesIds: Array) => { + const {companies} = this.props; + const companiesNames = companiesIds + .map((companyId) => companies.find(x => x._id === companyId)?.name) + .filter(x => x !== undefined); + + return this.renderSorted(companiesNames); + }; renderList() { - const {results} = this.state; + const {results} = this.props; const actions = this.getFilteredActions(); - if (get(results, 'length', 0) > 0) { - return results.map((item: any) => - - {item.versioncreated} - {item.headline} - {item.anpa_take_key} - {item.place.map((place: any) => ( - - {place}
-
- ))} - {item.service.map((service: any) => ( - - {service}
-
- ))} - {item.subject.map((subject: any) => ( - - {subject}
-
- ))} - {item.companies.map((company: any) => ( - - {company}
-
- ))} - {item.total} - {actions.includes('download') && {item.actions.download}} - {actions.includes('copy') && {item.actions.copy}} - {actions.includes('share') && {item.actions.share}} - {actions.includes('print') && {item.actions.print}} - {actions.includes('open') && {item.actions.open}} - {actions.includes('preview') && {item.actions.preview}} - {actions.includes('clipboard') && {item.actions.clipboard}} - {actions.includes('api') && {item.actions.api}} - - ); + if (results?.length > 0) { + return results.map((item: any) => { + const itemActions = item?.aggs?.actions || {}; + + return ( + + {formatTime(item?.versioncreated || '')} + {item.headline || ''} + {item.anpa_take_key || ''} + {this.renderNamesSorted(item?.place)} + {this.renderNamesSorted(item?.service)} + {this.renderNamesSorted(item?.subject)} + {item.source} + {this.renderCompaniesSorted(item?.aggs?.companies || [])} + {item?.aggs?.total || 0} + {actions.includes('download') && {itemActions?.download || 0}} + {actions.includes('copy') && {itemActions?.copy || 0}} + {actions.includes('share') && {itemActions?.share || 0}} + {actions.includes('print') && {itemActions?.print || 0}} + {actions.includes('open') && {itemActions?.open || 0}} + {actions.includes('preview') && {itemActions?.preview || 0}} + {actions.includes('clipboard') && {itemActions?.clipboard || 0}} + {actions.includes('api') && {itemActions?.api || 0}} + + ); + }); } return [ @@ -256,38 +149,21 @@ class ContentActivity extends React.Component { } render() { - const {print, reportParams} = this.props; - const {filters} = this.state; + const {print} = this.props; return (
-
- -
- - {filters.map((filterName: any) => { - const filter = this.state[filterName]; - - return ( - - ); - })} + + > + {gettext('Export to CSV')} +
{ } } -ContentActivity.propTypes = { - results: PropTypes.array, - print: PropTypes.bool, - companies: PropTypes.array, - runReport: PropTypes.func, - toggleFilter: PropTypes.func, - isLoading: PropTypes.bool, - fetchReport: PropTypes.func, - reportParams: PropTypes.object, - sections: PropTypes.array, - fetchAggregations: PropTypes.func, - aggregations: PropTypes.object, - apiEnabled: PropTypes.bool, -}; - const mapStateToProps = (state: any) => ({ companies: state.companies, - reportParams: state.reportParams, - isLoading: state.isLoading, - sections: state.sections, - aggregations: state.reportAggregations, + reportParams: state.reportParams }); const mapDispatchToProps: any = { - toggleFilter, - fetchReport, - runReport, - fetchAggregations, + fetchReport }; export default connect(mapStateToProps, mapDispatchToProps)(ContentActivity); diff --git a/assets/company-reports/components/ContentActivityFilters.tsx b/assets/company-reports/components/ContentActivityFilters.tsx new file mode 100644 index 000000000..51128572a --- /dev/null +++ b/assets/company-reports/components/ContentActivityFilters.tsx @@ -0,0 +1,137 @@ +import React, {useEffect, useMemo} from 'react'; +import {Dispatch} from 'redux'; +import {useDispatch, useSelector} from 'react-redux'; +import {gettext} from 'utils'; + +import MultiSelectDropdown from 'components/MultiSelectDropdown'; +import {REPORTS as REPORTS_API_ENDPOINTS} from 'company-reports/actions'; +import {toggleFilter, fetchAggregations} from '../actions'; +import CalendarButton from 'components/CalendarButton'; +import moment from 'moment'; + +export const ContentActivityFilters = () => { + const dispatch = useDispatch>(); + const { + sections, + reportParams, + apiEnabled, + companies, + reportAggregations + } = useSelector((state: any) => state); + + const internalToggleFilter = (filterName: string, value: any) => { + dispatch(toggleFilter(filterName, value)); + }; + + const fetchReportAggregations = () => { + dispatch(fetchAggregations(REPORTS_API_ENDPOINTS['content-activity'])); + }; + + const onChangeSection = (field: string, value: string) => { + internalToggleFilter('section', value); + fetchReportAggregations(); + }; + + const onDateChange = (value: any) => { + internalToggleFilter('date_from', value); + fetchReportAggregations(); + }; + + const genreOptions = useMemo(() => { + return (reportAggregations?.genres ?? []) + .sort() + .map((genre: string) => ({ + label: genre, + value: genre, + })); + }, [reportAggregations?.genres]); + + const companyOptions = useMemo(() => { + return companies + .filter((company: any) => reportAggregations?.companies.includes(company._id)) + .map((company: any) => ({ + label: company.name, + value: company.name, + })); + }, [companies, reportAggregations?.companies]); + + const filters = [ + { + field: 'section', + label: gettext('Section'), + options: sections + .filter((section: any) => + ['wire', 'monitoring'].includes(section.group) || + (section.group === 'api' && apiEnabled) + ) + .map((section: any) => ({ + label: section.name, + value: section.name, + })), + onChange: onChangeSection, + showAllButton: false, + multi: false, + default: sections[0]?.name, + }, + { + field: 'genre', + label: gettext('Genres'), + options: genreOptions, + onChange: internalToggleFilter, + showAllButton: true, + multi: true, + default: [], + }, + { + field: 'company', + label: gettext('Companies'), + options: companyOptions, + onChange: internalToggleFilter, + showAllButton: true, + multi: false, + default: null, + }, + { + field: 'action', + label: gettext('Actions'), + options: [ + {label: gettext('Download'), value: 'download'}, + {label: gettext('Copy'), value: 'copy'}, + {label: gettext('Share'), value: 'share'}, + {label: gettext('Print'), value: 'print'}, + {label: gettext('Open'), value: 'open'}, + {label: gettext('Preview'), value: 'preview'}, + {label: gettext('Clipboard'), value: 'clipboard'}, + {label: gettext('API retrieval'), value: 'api'}, + ], + onChange: internalToggleFilter, + showAllButton: true, + multi: true, + default: [], + } + ]; + + useEffect(fetchReportAggregations, []); + + return ( + <> +
+ +
+ + {filters.map((filter: any) => { + return ( + + ); + })} + + ); +}; diff --git a/assets/components/MultiSelectDropdown.tsx b/assets/components/MultiSelectDropdown.tsx index 14229d8f2..7fa69e98b 100644 --- a/assets/components/MultiSelectDropdown.tsx +++ b/assets/components/MultiSelectDropdown.tsx @@ -39,7 +39,6 @@ function MultiSelectDropdown({values, label, field, options, onChange, showAllBu {showAllButton && ( diff --git a/newsroom/reports/content_activity.py b/newsroom/reports/content_activity.py index bf55be833..13d6684a3 100644 --- a/newsroom/reports/content_activity.py +++ b/newsroom/reports/content_activity.py @@ -37,6 +37,7 @@ def get_items(args): "service", "versioncreated", "anpa_take_key", + "source", ], } @@ -189,6 +190,7 @@ def export_csv(args, results): gettext("Place"), gettext("Category"), gettext("Subject"), + gettext("Source"), gettext("Companies"), gettext("Actions"), ] @@ -239,6 +241,7 @@ def export_csv(args, results): "\r\n".join(sorted([place.get("name") or "" for place in item.get("place") or []])), "\r\n".join(sorted([service.get("name") or "" for service in item.get("service") or []])), "\r\n".join(sorted([subject.get("name") or "" for subject in item.get("subject") or []])), + item.get("source", ""), "\r\n".join( sorted( [ diff --git a/newsroom/templates/company_reports.html b/newsroom/templates/company_reports.html index 2d31940ab..2a2568e29 100644 --- a/newsroom/templates/company_reports.html +++ b/newsroom/templates/company_reports.html @@ -19,5 +19,4 @@ companyReportsData = {{ data | tojson | safe }}; {{ javascript_tag('%s_js' % setting_type) | safe }} - {% endblock %}