import React from 'react'; import 'react-table-6/react-table.css'; import ReactTable from 'react-table-6'; import { debounce, filter, isEmpty, pick, pickBy, toPairs } from 'lodash'; import moment from 'moment'; import './InstructorEnrollmentList.css'; import { getStateFromUrlParams, setUrlParamsFromState } from '../../lib'; import { getEnrollments } from '../../services/enrollments'; import { DATA_PROVIDER_PARAMETERS } from '../../config/data-provider-parameters'; import { INSTRUCTOR_STATUS_LABELS } from '../../config/status-parameters'; import enrollmentListStyle from './enrollmentListStyle'; import ScheduleIcon from '../atoms/icons/schedule'; import { AuthContext } from '../organisms/AuthContext'; import MultiSelect from '../molecules/MultiSelect'; import TagContainer from '../atoms/TagContainer'; import ListHeader from '../molecules/ListHeader'; import FileCopyIcon from '../atoms/icons/file_copy'; import useListItemNavigation from './hooks/use-list-item-navigation'; import Tag from '../atoms/hyperTexts/Tag'; import useFileDownloader from './hooks/use-file-downloader'; import Button from '../atoms/hyperTexts/Button'; import { useMatomo } from '@datapunt/matomo-tracker-react'; const getInboxes = (user) => ({ primary: { label: 'Habilitations en cours', sorted: [ { id: 'updated_at', desc: false, }, ], filtered: [ { id: 'status', value: isEmpty(user.roles) ? ['submitted', 'changes_requested', 'draft'] : ['submitted', 'changes_requested'], }, ], }, archive: { label: 'Habilitations traitées', sorted: [ { id: 'updated_at', desc: true, }, ], filtered: [ { id: 'status', value: ['validated', 'refused'], }, ], }, }); class InstructorEnrollmentList extends React.Component { constructor(props) { super(props); this.state = { enrollments: [], loading: true, totalPages: 0, page: 0, sorted: getInboxes(this.props.user)['primary'].sorted, filtered: getInboxes(this.props.user)['primary'].filtered, previouslySelectedEnrollmentId: 0, inbox: 'primary', }; } async componentDidMount() { try { const newState = getStateFromUrlParams( pick(this.state, [ 'inbox', 'page', 'sorted', 'filtered', 'previouslySelectedEnrollmentId', ]) ); this.setState(newState); } catch (e) { // silently fail, if the state from url is not properly formatted we do not apply url params console.error(e); } } componentDidUpdate(prevProps, prevState) { if ( prevState.totalPages === 0 && this.state.totalPages > prevState.totalPages ) { const pageMax = this.state.totalPages - 1; if (pageMax < this.state.page) { this.onPageChange(pageMax); } } setUrlParamsFromState( pick(this.state, ['inbox', 'page', 'sorted', 'filtered']) ); } availableAction = new Set([ 'validate', 'request_changes', 'refuse', 'submit', ]); hasTriggerableActions = ({ acl }) => !isEmpty( pickBy(acl, (value, key) => value && this.availableAction.has(key)) ); getColumnConfiguration = () => [ { Header: () => <ScheduleIcon title="date de dernière mise à jour" />, accessor: 'updated_at', headerStyle: { ...enrollmentListStyle.header, ...enrollmentListStyle.updateAtHeader, }, style: { ...enrollmentListStyle.cell, ...enrollmentListStyle.centeredCell, }, width: 50, sortable: true, Cell: ({ value: updatedAt }) => { if (this.state.inbox !== 'primary') { return <small>{moment(updatedAt).format('D/M')}</small>; } const daysFromToday = moment().diff(updatedAt, 'days'); const color = daysFromToday > 5 ? 'red' : daysFromToday > 4 ? 'orange' : 'green'; return <span style={{ color }}>{daysFromToday}j</span>; }, }, { Header: 'N°', accessor: 'id', headerStyle: enrollmentListStyle.header, style: { ...enrollmentListStyle.cell, ...enrollmentListStyle.centeredCell, }, width: 65, filterable: true, }, { Header: 'Raison sociale', accessor: 'nom_raison_sociale', headerStyle: enrollmentListStyle.header, style: enrollmentListStyle.cell, filterable: true, Placeholder: 'Filtrer par raison sociale', }, { Header: 'Email du demandeur', id: 'team_members.email', accessor: ({ demandeurs }) => demandeurs.map(({ email }) => email).join(', '), headerStyle: enrollmentListStyle.header, style: enrollmentListStyle.cell, filterable: true, Placeholder: 'Filtrer parmi tous les emails de contact', }, { Header: 'Fournisseur', accessor: ({ target_api }) => DATA_PROVIDER_PARAMETERS[target_api]?.label, id: 'target_api', headerStyle: enrollmentListStyle.header, style: enrollmentListStyle.cell, width: 130, filterable: true, Filter: ({ filter, onChange }) => { // Note that users own enrollments might not be available through this filter const options = this.props.user.roles .filter((role) => role.endsWith(':reporter')) .map((role) => { const targetApiKey = role.split(':')[0]; return { key: targetApiKey, label: DATA_PROVIDER_PARAMETERS[targetApiKey]?.label, }; }); return ( <MultiSelect options={options} values={filter ? filter.value : []} onChange={onChange} /> ); }, }, { Header: 'Statut', accessor: ({ status, acl, is_renewal }) => ({ statusLabel: INSTRUCTOR_STATUS_LABELS[status] || null, acl, isRenewal: is_renewal, }), id: 'status', headerStyle: enrollmentListStyle.header, style: { ...enrollmentListStyle.cell, ...enrollmentListStyle.centeredCell, }, width: 115, filterable: true, Cell: ({ value: { statusLabel, acl, isRenewal } }) => { if (!this.hasTriggerableActions({ acl })) { return ( <span> {statusLabel} {isRenewal ? ( <span style={{ marginLeft: '4px' }}> <FileCopyIcon size={16} /> </span> ) : ( '' )} </span> ); } return ( <Tag type="warning"> {statusLabel} {isRenewal ? ( <span style={{ marginLeft: '2px' }}> <FileCopyIcon color="white" size={14} /> </span> ) : ( '' )} </Tag> ); }, Filter: ({ filter, onChange }) => { const options = toPairs(INSTRUCTOR_STATUS_LABELS).map( ([key, label]) => ({ key, label, }) ); return ( <MultiSelect options={options} values={filter ? filter.value : []} onChange={onChange} /> ); }, }, ]; getTitle = ({ column, rowInfo }) => { if (!rowInfo) { return null; } // The idea here is to display content as tooltip in case the cell is not large enough to display its whole content const cellValue = rowInfo.row[column.id]; if (column.id === 'status') { return cellValue.statusLabel + (cellValue.isRenewal ? ' (copie)' : ''); } if (column.id === 'updated_at') { return moment(cellValue).format('llll'); } return cellValue; }; onPageChange = (newPage) => { this.setState({ page: newPage }); }; onSortedChange = (newSorted) => { this.setState({ sorted: newSorted }); }; onFilteredChange = (newFiltered) => { this.setState({ filtered: newFiltered, page: 0 }); }; onSelectInbox = (newInbox) => { // If the user clicks once, we change the inbox (primary or archive) without clearing filters. // If the user clicks twice, we stay on the inbox and we clear the filter. // That provides a way to clear all filter without reloading the page. let filtered = []; if (this.state.inbox !== newInbox) { filtered = filter(this.state.filtered, ({ id }) => id !== 'status'); } this.props.matomoTrackEvent({ category: 'instructor-enrollment-list', action: 'on-select-inbox', name: newInbox, }); this.setState({ inbox: newInbox, sorted: getInboxes(this.props.user)[newInbox].sorted, filtered: [ ...filtered, ...getInboxes(this.props.user)[newInbox].filtered, ], page: 0, previouslySelectedEnrollmentId: 0, }); }; savePreviouslySelectedEnrollmentId = (id) => { setUrlParamsFromState({ previouslySelectedEnrollmentId: id }); }; onFetchData = async () => { this.setState({ loading: true }); // Read the state from this.state and not from internally computed react table state // (passed as param of this function) as react table will reset page count to zero // on filter update. This breaks page selection on page load. const { page, sorted, filtered } = this.state; const { enrollments, meta: { total_pages: totalPages }, } = await getEnrollments({ page, sortBy: sorted, filter: filtered, }); this.setState({ enrollments, totalPages, loading: false, }); }; // this is a workaround for a react-table issue // see https://github.com/tannerlinsley/react-table/issues/1333#issuecomment-504046261 debouncedFetchData = debounce(this.onFetchData, 100); componentWillUnmount() { this.debouncedFetchData.cancel(); } render() { const { goToItem } = this.props; const { enrollments, loading, page, sorted, filtered, inbox, previouslySelectedEnrollmentId, totalPages, } = this.state; return ( <main> <ListHeader title="Liste des habilitations"> <TagContainer> {Object.keys(getInboxes(this.props.user)).map((currentInbox) => ( <Tag key={currentInbox} type={inbox === currentInbox ? 'info' : ''} onClick={() => this.onSelectInbox(currentInbox)} > {getInboxes(this.props.user)[currentInbox].label} </Tag> ))} </TagContainer> <Button onClick={() => this.props.downloadExport()} disabled={this.props.isExportDownloading} outline icon="file-download" iconRight > Exporter les données </Button> </ListHeader> <div className="table-container"> <ReactTable manual data={enrollments} pages={totalPages} columns={this.getColumnConfiguration()} getTdProps={(state, rowInfo, column) => ({ onClick: (e, handleOriginal) => { if (rowInfo) { const { original: { id, target_api }, } = rowInfo; this.savePreviouslySelectedEnrollmentId(id); goToItem(target_api, id, e); } if (handleOriginal) { handleOriginal(); } }, title: this.getTitle({ column, rowInfo }), className: rowInfo && rowInfo.original.id === previouslySelectedEnrollmentId ? 'selected' : null, })} getTheadProps={() => ({ style: enrollmentListStyle.thead })} getTheadFilterThProps={() => ({ style: enrollmentListStyle.filterThead, })} getPaginationProps={() => ({ style: enrollmentListStyle.pagination, })} style={enrollmentListStyle.table} className="-highlight" loading={loading} showPageSizeOptions={false} pageSize={10} page={page} onPageChange={this.onPageChange} sortable={false} sorted={sorted} onSortedChange={this.onSortedChange} filtered={filtered} onFilteredChange={this.onFilteredChange} onFetchData={this.debouncedFetchData} resizable={false} previousText="Précédent" nextText="Suivant" loadingText="Chargement..." noDataText={ inbox === 'primary' ? 'Toutes les demandes d’habilitation ont été traitées' : 'Aucune habilitation' } pageText="Page" ofText="sur" rowsText="lignes" /> </div> </main> ); } } const withMatomoTrackEvent = (Component) => { return (props) => { const { trackEvent } = useMatomo(); return <Component {...props} matomoTrackEvent={trackEvent} />; }; }; const withFileDownloader = (Component) => { return (props) => { const { isDownloading, download } = useFileDownloader(); const downloadExport = () => download('/api/enrollments/export', 'text/csv'); return ( <Component {...props} isExportDownloading={isDownloading} downloadExport={downloadExport} /> ); }; }; const withListItemNavigation = (Component) => { return (props) => { const { goToItem } = useListItemNavigation(); return <Component {...props} goToItem={goToItem} />; }; }; const withAuth = (Component) => { return (props) => ( <AuthContext.Consumer> {(userProps) => <Component {...props} {...userProps} />} </AuthContext.Consumer> ); }; export default withMatomoTrackEvent( withFileDownloader(withListItemNavigation(withAuth(InstructorEnrollmentList))) );