From cc6d7629187b93227ea8bea22bc6b1d33ff5b776 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sun, 13 Aug 2023 15:49:46 -0500 Subject: [PATCH 1/4] common Table component --- client/common/Table/TableBase.jsx | 104 +++++ client/common/Table/TableBase.test.jsx | 63 +++ client/common/Table/TableElements.jsx | 10 + client/common/Table/TableHeaderCell.jsx | 100 +++++ client/common/Table/TableHeaderCell.test.jsx | 89 ++++ client/constants.js | 3 - client/modules/IDE/actions/collections.js | 7 +- client/modules/IDE/actions/projects.js | 3 +- client/modules/IDE/actions/sorting.js | 21 - .../IDE/components/AddToCollectionList.jsx | 5 +- .../components/AddToCollectionSketchList.jsx | 14 +- client/modules/IDE/components/AssetList.jsx | 178 +++----- .../CollectionList/CollectionList.jsx | 406 +++++------------- .../CollectionList/CollectionListRow.jsx | 7 +- client/modules/IDE/components/SketchList.jsx | 368 +++++----------- .../IDE/components/SketchList.unit.test.jsx | 21 +- .../SketchList.unit.test.jsx.snap | 20 +- client/modules/IDE/reducers/sorting.js | 33 -- client/modules/IDE/selectors/collections.js | 34 +- client/modules/IDE/selectors/projects.js | 29 +- client/modules/IDE/selectors/users.js | 2 + client/modules/User/components/Collection.jsx | 194 +++------ client/reducers.js | 2 - client/styles/components/_asset-list.scss | 38 -- client/styles/components/_collection.scss | 5 - client/styles/components/_sketch-list.scss | 50 --- client/theme.js | 3 + 27 files changed, 754 insertions(+), 1055 deletions(-) create mode 100644 client/common/Table/TableBase.jsx create mode 100644 client/common/Table/TableBase.test.jsx create mode 100644 client/common/Table/TableElements.jsx create mode 100644 client/common/Table/TableHeaderCell.jsx create mode 100644 client/common/Table/TableHeaderCell.test.jsx delete mode 100644 client/modules/IDE/reducers/sorting.js diff --git a/client/common/Table/TableBase.jsx b/client/common/Table/TableBase.jsx new file mode 100644 index 0000000000..fbdc5998a4 --- /dev/null +++ b/client/common/Table/TableBase.jsx @@ -0,0 +1,104 @@ +import classNames from 'classnames'; +import { orderBy } from 'lodash'; +import PropTypes from 'prop-types'; +import React, { useState, useMemo } from 'react'; +import Loader from '../../modules/App/components/loader'; +import { DIRECTION } from '../../modules/IDE/actions/sorting'; +import { TableEmpty } from './TableElements'; +import TableHeaderCell, { StyledHeaderCell } from './TableHeaderCell'; + +const toAscDesc = (direction) => (direction === DIRECTION.ASC ? 'asc' : 'desc'); + +/** + * Renders the headers, loading spinner, empty message. + * Applies sorting to the items. + * Expects a `renderRow` prop to render each row. + */ +function TableBase({ + initialSort, + columns, + items = [], + isLoading, + emptyMessage, + caption, + addDropdownColumn, + renderRow, + className +}) { + const [sorting, setSorting] = useState(initialSort); + + const sortedItems = useMemo( + () => orderBy(items, sorting.field, toAscDesc(sorting.direction)), + [sorting.field, sorting.direction, items] + ); + + if (isLoading) { + return ; + } + + if (items.length === 0) { + return {emptyMessage}; + } + + return ( + . + summary={caption} + > + + + {columns.map((column) => ( + + ))} + {addDropdownColumn && } + + + {sortedItems.map((item) => renderRow(item))} + + ); +} + +TableBase.propTypes = { + initialSort: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired, + columns: PropTypes.arrayOf( + PropTypes.shape({ + field: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + defaultOrder: PropTypes.oneOf([DIRECTION.ASC, DIRECTION.DESC]), + formatValue: PropTypes.func + }) + ).isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired + // Will have other properties, depending on the type. + }) + ), + renderRow: PropTypes.func.isRequired, + addDropdownColumn: PropTypes.bool, + isLoading: PropTypes.bool, + emptyMessage: PropTypes.string.isRequired, + caption: PropTypes.string, + className: PropTypes.string +}; + +TableBase.defaultProps = { + items: [], + isLoading: false, + caption: '', + addDropdownColumn: false, + className: '' +}; + +export default TableBase; diff --git a/client/common/Table/TableBase.test.jsx b/client/common/Table/TableBase.test.jsx new file mode 100644 index 0000000000..d49cd395f9 --- /dev/null +++ b/client/common/Table/TableBase.test.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { DIRECTION } from '../../modules/IDE/actions/sorting'; +import { render, screen } from '../../test-utils'; +import TableBase from './TableBase'; + +describe('', () => { + const items = [ + { id: '1', name: 'abc', count: 3 }, + { id: '2', name: 'def', count: 10 } + ]; + + const props = { + items, + initialSort: { field: 'count', direction: DIRECTION.DESC }, + emptyMessage: 'No items found', + renderRow: (item) => , + columns: [] + }; + + const subject = (overrideProps) => + render(); + + jest.spyOn(props, 'renderRow'); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('shows a spinner when loading', () => { + subject({ isLoading: true }); + + expect(document.querySelector('.loader')).toBeInTheDocument(); + }); + + it('show the `emptyMessage` when there are no items', () => { + subject({ items: [] }); + + expect(screen.getByText(props.emptyMessage)).toBeVisible(); + }); + + it('calls `renderRow` function for each row', () => { + subject(); + + expect(props.renderRow).toHaveBeenCalledTimes(2); + }); + + it('sorts the items', () => { + subject(); + + expect(props.renderRow).toHaveBeenNthCalledWith(1, items[1]); + expect(props.renderRow).toHaveBeenNthCalledWith(2, items[0]); + }); + + it('does not add an extra header if `addDropdownColumn` is false', () => { + subject({ addDropdownColumn: false }); + expect(screen.queryByRole('columnheader')).not.toBeInTheDocument(); + }); + + it('adds an extra header if `addDropdownColumn` is true', () => { + subject({ addDropdownColumn: true }); + expect(screen.getByRole('columnheader')).toBeInTheDocument(); + }); +}); diff --git a/client/common/Table/TableElements.jsx b/client/common/Table/TableElements.jsx new file mode 100644 index 0000000000..83656c7488 --- /dev/null +++ b/client/common/Table/TableElements.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styled from 'styled-components'; +import { remSize } from '../../theme'; + +// eslint-disable-next-line import/prefer-default-export +export const TableEmpty = styled.p` + text-align: center; + font-size: ${remSize(16)}; + padding: ${remSize(42)} 0; +`; diff --git a/client/common/Table/TableHeaderCell.jsx b/client/common/Table/TableHeaderCell.jsx new file mode 100644 index 0000000000..99cec86eac --- /dev/null +++ b/client/common/Table/TableHeaderCell.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { DIRECTION } from '../../modules/IDE/actions/sorting'; +import { prop, remSize } from '../../theme'; +import { SortArrowDownIcon, SortArrowUpIcon } from '../icons'; + +const opposite = (direction) => + direction === DIRECTION.ASC ? DIRECTION.DESC : DIRECTION.ASC; + +const ariaSort = (direction) => + direction === DIRECTION.ASC ? 'ascending' : 'descending'; + +const TableHeaderTitle = styled.span` + border-bottom: 2px dashed transparent; + padding: ${remSize(3)} 0; + color: ${prop('inactiveTextColor')}; + ${(props) => props.selected && `border-color: ${prop('accentColor')(props)}`} +`; + +export const StyledHeaderCell = styled.th` + height: ${remSize(32)}; + position: sticky; + top: 0; + z-index: 1; + background-color: ${prop('backgroundColor')}; + font-weight: normal; + &:nth-child(1) { + padding-left: ${remSize(12)}; + } + button { + display: flex; + align-items: center; + height: ${remSize(35)}; + svg { + margin-left: ${remSize(8)}; + fill: ${prop('inactiveTextColor')}; + } + } +`; + +const TableHeaderCell = ({ sorting, field, title, defaultOrder, onSort }) => { + const isSelected = sorting.field === field; + const { direction } = sorting; + const { t } = useTranslation(); + const directionWhenClicked = isSelected ? opposite(direction) : defaultOrder; + // TODO: more generic translation properties + const translationKey = + directionWhenClicked === DIRECTION.ASC + ? 'SketchList.ButtonLabelAscendingARIA' + : 'SketchList.ButtonLabelDescendingARIA'; + const buttonLabel = t(translationKey, { + displayName: title + }); + + return ( + + + + ); +}; + +TableHeaderCell.propTypes = { + sorting: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired, + field: PropTypes.string.isRequired, + title: PropTypes.string, + defaultOrder: PropTypes.oneOf([DIRECTION.ASC, DIRECTION.DESC]), + onSort: PropTypes.func.isRequired +}; + +TableHeaderCell.defaultProps = { + title: '', + defaultOrder: DIRECTION.ASC +}; + +export default TableHeaderCell; diff --git a/client/common/Table/TableHeaderCell.test.jsx b/client/common/Table/TableHeaderCell.test.jsx new file mode 100644 index 0000000000..b6bf0bda33 --- /dev/null +++ b/client/common/Table/TableHeaderCell.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { DIRECTION } from '../../modules/IDE/actions/sorting'; +import { render, fireEvent, screen } from '../../test-utils'; +import TableHeaderCell from './TableHeaderCell'; + +describe('', () => { + const onSort = jest.fn(); + + const table = document.createElement('table'); + const tr = document.createElement('tr'); + table.appendChild(tr); + document.body.appendChild(table); + + const subject = (sorting, defaultOrder = DIRECTION.ASC) => + render( + , + { container: tr } + ); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('indicates the active sort order', () => { + it('shows an up arrow when active ascending', () => { + subject({ field: 'name', direction: DIRECTION.ASC }); + expect(screen.getByRole('columnheader')).toHaveAttribute( + 'aria-sort', + 'ascending' + ); + expect(screen.getByLabelText('Ascending')).toBeVisible(); + expect(screen.queryByLabelText('Descending')).not.toBeInTheDocument(); + }); + + it('shows a down arrow when active descending', () => { + subject({ field: 'name', direction: DIRECTION.DESC }); + expect(screen.getByRole('columnheader')).toHaveAttribute( + 'aria-sort', + 'descending' + ); + expect(screen.queryByLabelText('Ascending')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Descending')).toBeVisible(); + }); + + it('has no icon when inactive', () => { + subject({ field: 'other', direction: DIRECTION.ASC }); + expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort'); + expect(screen.queryByLabelText('Ascending')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Descending')).not.toBeInTheDocument(); + }); + }); + + describe('calls onSort when clicked', () => { + const checkSort = (expectedDirection) => { + fireEvent.click(screen.getByText('Name')); + + expect(onSort).toHaveBeenCalledWith({ + field: 'name', + direction: expectedDirection + }); + }; + + it('uses defaultOrder when inactive, ascending', () => { + subject({ field: 'other', direction: DIRECTION.ASC }, DIRECTION.ASC); + checkSort(DIRECTION.ASC); + }); + + it('uses defaultOrder when inactive, descending', () => { + subject({ field: 'other', direction: DIRECTION.ASC }, DIRECTION.DESC); + checkSort(DIRECTION.DESC); + }); + + it('calls with DESC if currently sorted ASC', () => { + subject({ field: 'name', direction: DIRECTION.ASC }); + checkSort(DIRECTION.DESC); + }); + + it('calls with ASC if currently sorted DESC', () => { + subject({ field: 'name', direction: DIRECTION.DESC }); + checkSort(DIRECTION.ASC); + }); + }); +}); diff --git a/client/constants.js b/client/constants.js index ec0e4107ac..b4b5aa8cac 100644 --- a/client/constants.js +++ b/client/constants.js @@ -135,9 +135,6 @@ export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING'; export const SET_ASSETS = 'SET_ASSETS'; export const DELETE_ASSET = 'DELETE_ASSET'; -export const TOGGLE_DIRECTION = 'TOGGLE_DIRECTION'; -export const SET_SORTING = 'SET_SORTING'; -export const SET_SORT_PARAMS = 'SET_SORT_PARAMS'; export const SET_SEARCH_TERM = 'SET_SEARCH_TERM'; export const CLOSE_SKETCHLIST_MODAL = 'CLOSE_SKETCHLIST_MODAL'; diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index 5a9218520b..dbd2cdae48 100644 --- a/client/modules/IDE/actions/collections.js +++ b/client/modules/IDE/actions/collections.js @@ -6,7 +6,6 @@ import { setToastText, showToast } from './toast'; const TOAST_DISPLAY_TIME_MS = 1500; -// eslint-disable-next-line export function getCollections(username) { return (dispatch) => { dispatch(startLoader()); @@ -16,8 +15,7 @@ export function getCollections(username) { } else { url = '/collections'; } - console.log(url); - apiClient + return apiClient .get(url) .then((response) => { dispatch({ @@ -27,10 +25,9 @@ export function getCollections(username) { dispatch(stopLoader()); }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); dispatch(stopLoader()); }); diff --git a/client/modules/IDE/actions/projects.js b/client/modules/IDE/actions/projects.js index 4072429af4..eb9984cf54 100644 --- a/client/modules/IDE/actions/projects.js +++ b/client/modules/IDE/actions/projects.js @@ -22,10 +22,9 @@ export function getProjects(username) { dispatch(stopLoader()); }) .catch((error) => { - const { response } = error; dispatch({ type: ActionTypes.ERROR, - error: response.data + error: error?.response?.data }); dispatch(stopLoader()); }); diff --git a/client/modules/IDE/actions/sorting.js b/client/modules/IDE/actions/sorting.js index b9aa0354cb..07e040b896 100644 --- a/client/modules/IDE/actions/sorting.js +++ b/client/modules/IDE/actions/sorting.js @@ -5,27 +5,6 @@ export const DIRECTION = { DESC: 'DESCENDING' }; -export function setSorting(field, direction) { - return { - type: ActionTypes.SET_SORTING, - payload: { - field, - direction - } - }; -} - -export function resetSorting() { - return setSorting('createdAt', DIRECTION.DESC); -} - -export function toggleDirectionForField(field) { - return { - type: ActionTypes.TOGGLE_DIRECTION, - field - }; -} - export function setSearchTerm(scope, searchTerm) { return { type: ActionTypes.SET_SEARCH_TERM, diff --git a/client/modules/IDE/components/AddToCollectionList.jsx b/client/modules/IDE/components/AddToCollectionList.jsx index 26addfaa34..0c4b5852c6 100644 --- a/client/modules/IDE/components/AddToCollectionList.jsx +++ b/client/modules/IDE/components/AddToCollectionList.jsx @@ -9,7 +9,6 @@ import * as ProjectActions from '../actions/project'; import * as ProjectsActions from '../actions/projects'; import * as CollectionsActions from '../actions/collections'; import * as ToastActions from '../actions/toast'; -import * as SortingActions from '../actions/sorting'; import getSortedCollections from '../selectors/collections'; import Loader from '../../App/components/loader'; import QuickAddList from './QuickAddList'; @@ -157,7 +156,6 @@ function mapStateToProps(state, ownProps) { return { user: state.user, collections: getSortedCollections(state), - sorting: state.sorting, loading: state.loading, project: ownProps.project || state.project, projectId: ownProps && ownProps.params ? ownProps.prams.project_id : null @@ -171,8 +169,7 @@ function mapDispatchToProps(dispatch) { CollectionsActions, ProjectsActions, ProjectActions, - ToastActions, - SortingActions + ToastActions ), dispatch ); diff --git a/client/modules/IDE/components/AddToCollectionSketchList.jsx b/client/modules/IDE/components/AddToCollectionSketchList.jsx index e42aff8d60..c91aee6d19 100644 --- a/client/modules/IDE/components/AddToCollectionSketchList.jsx +++ b/client/modules/IDE/components/AddToCollectionSketchList.jsx @@ -8,7 +8,6 @@ import { withTranslation } from 'react-i18next'; import * as ProjectsActions from '../actions/projects'; import * as CollectionsActions from '../actions/collections'; import * as ToastActions from '../actions/toast'; -import * as SortingActions from '../actions/sorting'; import getSortedSketches from '../selectors/projects'; import Loader from '../../App/components/loader'; import QuickAddList from './QuickAddList'; @@ -120,10 +119,6 @@ SketchList.propTypes = { }).isRequired, username: PropTypes.string, loading: PropTypes.bool.isRequired, - sorting: PropTypes.shape({ - field: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired - }).isRequired, addToCollection: PropTypes.func.isRequired, removeFromCollection: PropTypes.func.isRequired, t: PropTypes.func.isRequired @@ -137,7 +132,6 @@ function mapStateToProps(state) { return { user: state.user, sketches: getSortedSketches(state), - sorting: state.sorting, loading: state.loading, project: state.project }; @@ -145,13 +139,7 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return bindActionCreators( - Object.assign( - {}, - ProjectsActions, - CollectionsActions, - ToastActions, - SortingActions - ), + Object.assign({}, ProjectsActions, CollectionsActions, ToastActions), dispatch ); } diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index 559f60c580..1014dfe169 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -1,15 +1,14 @@ +import prettyBytes from 'pretty-bytes'; import PropTypes from 'prop-types'; -import React from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { Link } from 'react-router-dom'; +import React, { useEffect, useMemo } from 'react'; import { Helmet } from 'react-helmet'; -import prettyBytes from 'pretty-bytes'; -import { withTranslation } from 'react-i18next'; - -import Loader from '../../App/components/loader'; -import * as AssetActions from '../actions/assets'; +import { useTranslation } from 'react-i18next'; +import { connect, useDispatch, useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import TableBase from '../../../common/Table/TableBase'; import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; +import { deleteAssetRequest, getAssets } from '../actions/assets'; +import { DIRECTION } from '../actions/sorting'; class AssetListRowBase extends React.Component { constructor(props) { @@ -145,106 +144,67 @@ function mapStateToPropsAssetListRow(state) { }; } -function mapDispatchToPropsAssetListRow(dispatch) { - return bindActionCreators(AssetActions, dispatch); -} - -const AssetListRow = connect( - mapStateToPropsAssetListRow, - mapDispatchToPropsAssetListRow -)(AssetListRowBase); - -class AssetList extends React.Component { - constructor(props) { - super(props); - this.props.getAssets(); - } - - getAssetsTitle() { - return this.props.t('AssetList.Title'); - } - - hasAssets() { - return !this.props.loading && this.props.assetList.length > 0; - } - - renderLoader() { - if (this.props.loading) return ; - return null; - } - - renderEmptyTable() { - if (!this.props.loading && this.props.assetList.length === 0) { - return ( -

- {this.props.t('AssetList.NoUploadedAssets')} -

- ); - } - return null; - } - - render() { - const { assetList, t } = this.props; - return ( -
- - {this.getAssetsTitle()} - - {this.renderLoader()} - {this.renderEmptyTable()} - {this.hasAssets() && ( - - - - - - - - - - - {assetList.map((asset) => ( - - ))} - -
{t('AssetList.HeaderName')}{t('AssetList.HeaderSize')}{t('AssetList.HeaderSketch')}
+const AssetListRow = connect(mapStateToPropsAssetListRow, { + deleteAssetRequest +})(AssetListRowBase); + +const AssetList = () => { + const { t } = useTranslation(); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(getAssets()); + }, []); + + const isLoading = useSelector((state) => state.loading); + + const assetList = useSelector((state) => state.assets.list); + + const items = useMemo( + // This is a hack to use the order from the API as the initial sort + () => assetList?.map((asset, i) => ({ ...asset, index: i, id: asset.key })), + [assetList] + ); + + return ( +
+ + {t('AssetList.Title')} + + ( + )} -
- ); - } -} - -AssetList.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string - }).isRequired, - assetList: PropTypes.arrayOf( - PropTypes.shape({ - key: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - sketchName: PropTypes.string, - sketchId: PropTypes.string - }) - ).isRequired, - getAssets: PropTypes.func.isRequired, - loading: PropTypes.bool.isRequired, - t: PropTypes.func.isRequired + /> +
+ ); }; -function mapStateToProps(state) { - return { - user: state.user, - assetList: state.assets.list, - loading: state.loading - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators(Object.assign({}, AssetActions), dispatch); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(AssetList) -); +export default AssetList; diff --git a/client/modules/IDE/components/CollectionList/CollectionList.jsx b/client/modules/IDE/components/CollectionList/CollectionList.jsx index 646b9824b5..38a3dae8e8 100644 --- a/client/modules/IDE/components/CollectionList/CollectionList.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionList.jsx @@ -1,314 +1,130 @@ +import find from 'lodash/find'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; -import { withTranslation } from 'react-i18next'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import classNames from 'classnames'; -import find from 'lodash/find'; -import * as ProjectActions from '../../actions/project'; -import * as ProjectsActions from '../../actions/projects'; -import * as CollectionsActions from '../../actions/collections'; -import * as ToastActions from '../../actions/toast'; -import * as SortingActions from '../../actions/sorting'; -import getSortedCollections from '../../selectors/collections'; -import Loader from '../../../App/components/loader'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import TableBase from '../../../../common/Table/TableBase'; import Overlay from '../../../App/components/Overlay'; +import { getCollections } from '../../actions/collections'; +import { DIRECTION } from '../../actions/sorting'; +import getSortedCollections from '../../selectors/collections'; +import { selectCurrentUsername } from '../../selectors/users'; import AddToCollectionSketchList from '../AddToCollectionSketchList'; import { SketchSearchbar } from '../Searchbar'; import CollectionListRow from './CollectionListRow'; -import ArrowUpIcon from '../../../../images/sort-arrow-up.svg'; -import ArrowDownIcon from '../../../../images/sort-arrow-down.svg'; - -class CollectionList extends React.Component { - constructor(props) { - super(props); - - if (props.projectId) { - props.getProject(props.projectId); - } - - this.props.getCollections(this.props.username); - this.props.resetSorting(); - - this.state = { - hasLoadedData: false, - addingSketchesToCollectionId: null - }; - } - - componentDidUpdate(prevProps, prevState) { - if (prevProps.loading === true && this.props.loading === false) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - hasLoadedData: true - }); - } - } - - getTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('CollectionList.Title'); - } - return this.props.t('CollectionList.AnothersTitle', { - anotheruser: this.props.username - }); - } - - showAddSketches = (collectionId) => { - this.setState({ - addingSketchesToCollectionId: collectionId - }); - }; - - hideAddSketches = () => { - this.setState({ - addingSketchesToCollectionId: null - }); - }; - - hasCollections() { - return ( - (!this.props.loading || this.state.hasLoadedData) && - this.props.collections.length > 0 - ); - } - - _renderLoader() { - if (this.props.loading && !this.state.hasLoadedData) return ; - return null; - } - - _renderEmptyTable() { - if (!this.props.loading && this.props.collections.length === 0) { - return ( -

- {this.props.t('CollectionList.NoCollections')} -

- ); - } - return null; - } - - _getButtonLabel = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - let buttonLabel; - if (field !== fieldName) { - if (field === 'name') { - buttonLabel = this.props.t('CollectionList.ButtonLabelAscendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('CollectionList.ButtonLabelDescendingARIA', { - displayName - }); - } - } else if (direction === SortingActions.DIRECTION.ASC) { - buttonLabel = this.props.t('CollectionList.ButtonLabelDescendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('CollectionList.ButtonLabelAscendingARIA', { - displayName - }); - } - return buttonLabel; - }; - - _renderFieldHeader = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - const headerClass = classNames({ - 'sketches-table__header': true, - 'sketches-table__header--selected': field === fieldName - }); - const buttonLabel = this._getButtonLabel(fieldName, displayName); - return ( - - - - ); - }; - - render() { - const username = - this.props.username !== undefined - ? this.props.username - : this.props.user.username; - const { mobile } = this.props; - - return ( -
- - {this.getTitle()} - - - {this._renderLoader()} - {this._renderEmptyTable()} - {this.hasCollections() && ( - - - - {this._renderFieldHeader( - 'name', - this.props.t('CollectionList.HeaderName') - )} - {this._renderFieldHeader( - 'createdAt', - this.props.t('CollectionList.HeaderCreatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {this._renderFieldHeader( - 'updatedAt', - this.props.t('CollectionList.HeaderUpdatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {this._renderFieldHeader( - 'numItems', - this.props.t('CollectionList.HeaderNumItems', { - context: mobile ? 'mobile' : '' - }) - )} - - - - - {this.props.collections.map((collection) => ( - this.showAddSketches(collection.id)} - /> - ))} - -
- )} - {this.state.addingSketchesToCollectionId && ( - } - closeOverlay={this.hideAddSketches} - isFixedHeight - > - { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const collections = useSelector(getSortedCollections); + + // TODO: combine with AddToCollectionList + const loading = useSelector((state) => state.loading); + const [hasLoadedData, setHasLoadedData] = useState(false); + const showLoader = loading && !hasLoadedData; + + useEffect(() => { + dispatch(getCollections(username)).then(() => setHasLoadedData(true)); + }, [dispatch, username]); + + const currentUser = useSelector(selectCurrentUsername); + const userIsOwner = username === currentUser; + + const [ + addingSketchesToCollectionId, + setAddingSketchesToCollectionId + ] = useState(null); + + return ( +
+ + + {userIsOwner + ? t('CollectionList.Title') + : t('CollectionList.AnothersTitle', { + anotheruser: username })} - /> - </Overlay> + + + + ( + setAddingSketchesToCollectionId(collection.id)} + /> )} -
- ); - } -} + /> + {addingSketchesToCollectionId && ( + } + closeOverlay={() => setAddingSketchesToCollectionId(null)} + isFixedHeight + > + + + )} +
+ ); +}; CollectionList.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - projectId: PropTypes.string, - getCollections: PropTypes.func.isRequired, - getProject: PropTypes.func.isRequired, - collections: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - description: PropTypes.string, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired - }) - ).isRequired, - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - toggleDirectionForField: PropTypes.func.isRequired, - resetSorting: PropTypes.func.isRequired, - sorting: PropTypes.shape({ - field: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired - }).isRequired, - project: PropTypes.shape({ - id: PropTypes.string, - owner: PropTypes.shape({ - id: PropTypes.string - }) - }), - t: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, mobile: PropTypes.bool }; CollectionList.defaultProps = { - projectId: undefined, - project: { - id: undefined, - owner: undefined - }, - username: undefined, mobile: false }; -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collections: getSortedCollections(state), - sorting: state.sorting, - loading: state.loading, - project: state.project, - projectId: ownProps && ownProps.params ? ownProps.params.project_id : null - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ProjectActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(CollectionList) -); +export default CollectionList; diff --git a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index ed109141d7..e61184e234 100644 --- a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx @@ -133,7 +133,7 @@ class CollectionListRowBase extends React.Component { renderActions = () => { const { optionsOpen } = this.state; - const userIsOwner = this.props.user.username === this.props.username; + const { userIsOwner } = this.props; return ( @@ -264,10 +264,7 @@ CollectionListRowBase.propTypes = { ) }).isRequired, username: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, + userIsOwner: PropTypes.bool.isRequired, deleteCollection: PropTypes.func.isRequired, editCollection: PropTypes.func.isRequired, onAddSketches: PropTypes.func.isRequired, diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index af03453e0d..8985714470 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -1,27 +1,24 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; -import { withTranslation } from 'react-i18next'; -import { connect } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { connect, useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { bindActionCreators } from 'redux'; -import classNames from 'classnames'; import slugify from 'slugify'; + +import TableBase from '../../../common/Table/TableBase'; import dates from '../../../utils/formatDate'; -import * as ProjectActions from '../actions/project'; -import * as ProjectsActions from '../actions/projects'; -import * as CollectionsActions from '../actions/collections'; -import * as ToastActions from '../actions/toast'; -import * as SortingActions from '../actions/sorting'; +import Overlay from '../../App/components/Overlay'; import * as IdeActions from '../actions/ide'; +import * as ProjectActions from '../actions/project'; +import { getProjects } from '../actions/projects'; +import { DIRECTION } from '../actions/sorting'; import getSortedSketches from '../selectors/projects'; -import Loader from '../../App/components/loader'; -import Overlay from '../../App/components/Overlay'; +import { selectCurrentUsername } from '../selectors/users'; import AddToCollectionList from './AddToCollectionList'; import getConfig from '../../../utils/getConfig'; -import ArrowUpIcon from '../../../images/sort-arrow-up.svg'; -import ArrowDownIcon from '../../../images/sort-arrow-down.svg'; import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; const ROOT_URL = getConfig('API_URL'); @@ -351,6 +348,12 @@ SketchListRowBase.defaultProps = { mobile: false }; +function mapStateToPropsSketchListRow(state) { + return { + user: state.user + }; +} + function mapDispatchToPropsSketchListRow(dispatch) { return bindActionCreators( Object.assign({}, ProjectActions, IdeActions), @@ -359,257 +362,112 @@ function mapDispatchToPropsSketchListRow(dispatch) { } const SketchListRow = connect( - null, + mapStateToPropsSketchListRow, mapDispatchToPropsSketchListRow )(SketchListRowBase); -class SketchList extends React.Component { - constructor(props) { - super(props); - this.props.getProjects(this.props.username); - this.props.resetSorting(); - - this.state = { - isInitialDataLoad: true - }; - } - - componentDidUpdate(prevProps) { - if ( - this.props.sketches !== prevProps.sketches && - Array.isArray(this.props.sketches) - ) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - isInitialDataLoad: false - }); - } - } - - getSketchesTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('SketchList.Title'); - } - return this.props.t('SketchList.AnothersTitle', { - anotheruser: this.props.username - }); - } - - hasSketches() { - return !this.isLoading() && this.props.sketches.length > 0; - } - - isLoading() { - return this.props.loading && this.state.isInitialDataLoad; - } - - _renderLoader() { - if (this.isLoading()) return ; - return null; - } - - _renderEmptyTable() { - if (!this.isLoading() && this.props.sketches.length === 0) { - return ( -

- {this.props.t('SketchList.NoSketches')} -

- ); - } - return null; - } - - _getButtonLabel = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - let buttonLabel; - if (field !== fieldName) { - if (field === 'name') { - buttonLabel = this.props.t('SketchList.ButtonLabelAscendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('SketchList.ButtonLabelDescendingARIA', { - displayName - }); - } - } else if (direction === SortingActions.DIRECTION.ASC) { - buttonLabel = this.props.t('SketchList.ButtonLabelDescendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('SketchList.ButtonLabelAscendingARIA', { - displayName - }); - } - return buttonLabel; - }; - - _renderFieldHeader = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - const headerClass = classNames({ - 'sketches-table__header': true, - 'sketches-table__header--selected': field === fieldName - }); - const buttonLabel = this._getButtonLabel(fieldName, displayName); - return ( - - - - ); - }; - - render() { - const username = - this.props.username !== undefined - ? this.props.username - : this.props.user.username; - const { mobile } = this.props; - return ( -
- - {this.getSketchesTitle()} - - {this._renderLoader()} - {this._renderEmptyTable()} - {this.hasSketches() && ( - - - - {this._renderFieldHeader( - 'name', - this.props.t('SketchList.HeaderName') - )} - {this._renderFieldHeader( - 'createdAt', - this.props.t('SketchList.HeaderCreatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {this._renderFieldHeader( - 'updatedAt', - this.props.t('SketchList.HeaderUpdatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - - - - - {this.props.sketches.map((sketch) => ( - { - this.setState({ sketchToAddToCollection: sketch }); - }} - t={this.props.t} - /> - ))} - -
- )} - {this.state.sketchToAddToCollection && ( - - this.setState({ sketchToAddToCollection: null }) - } - > - - +const SketchList = ({ username, mobile }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const currentUser = useSelector(selectCurrentUsername); + + const sketches = useSelector(getSortedSketches); + + // TODO: combine with AddToCollectionSketchList + const loading = useSelector((state) => state.loading); + const [hasLoadedData, setHasLoadedData] = useState(false); + const showLoader = loading && !hasLoadedData; + + useEffect(() => { + dispatch(getProjects(username)).then(() => setHasLoadedData(true)); + }, [dispatch, username]); + + const [sketchToAddToCollection, setSketchToAddToCollection] = useState(null); + + return ( +
+ + + {username === currentUser + ? t('SketchList.Title') + : t('SketchList.AnothersTitle', { + anotheruser: username + })} + + + formatDateCell(value, mobile) + }, + { + field: 'updatedAt', + defaultOrder: DIRECTION.DESC, + title: t('SketchList.HeaderUpdatedAt', { + context: mobile ? 'mobile' : '' + }), + formatValue: (value) => formatDateCell(value, mobile) + } + ]} + addDropdownColumn + initialSort={{ + field: 'createdAt', + direction: DIRECTION.DESC + }} + emptyMessage={t('SketchList.NoSketches')} + caption={t('SketchList.TableSummary')} + // TODO: figure out how to use the StandardTable -- needs dropdown and styling + renderRow={(sketch) => ( + { + setSketchToAddToCollection(sketch); + }} + t={t} + /> )} -
- ); - } -} + /> + {sketchToAddToCollection && ( + { + setSketchToAddToCollection(null); + }} + > + + + )} +
+ ); +}; SketchList.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - getProjects: PropTypes.func.isRequired, - sketches: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired - }) - ).isRequired, - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - toggleDirectionForField: PropTypes.func.isRequired, - resetSorting: PropTypes.func.isRequired, - sorting: PropTypes.shape({ - field: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired - }).isRequired, - mobile: PropTypes.bool, - t: PropTypes.func.isRequired + username: PropTypes.string.isRequired, + mobile: PropTypes.bool }; SketchList.defaultProps = { - username: undefined, mobile: false }; -function mapStateToProps(state) { - return { - user: state.user, - sketches: getSortedSketches(state), - sorting: state.sorting, - loading: state.loading, - project: state.project - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - ProjectsActions, - CollectionsActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(SketchList) -); +export default SketchList; diff --git a/client/modules/IDE/components/SketchList.unit.test.jsx b/client/modules/IDE/components/SketchList.unit.test.jsx index 162d12bcc1..b72dcaeb3f 100644 --- a/client/modules/IDE/components/SketchList.unit.test.jsx +++ b/client/modules/IDE/components/SketchList.unit.test.jsx @@ -44,17 +44,32 @@ describe('', () => { expect(screen.getByText('testsketch2')).toBeInTheDocument(); }); - it('clicking on date created row header dispatches a reordering action', () => { + it('clicking on date created row header sorts the table', () => { act(() => { subject(); }); + expect.assertions(6); + + const rowsBefore = screen.getAllByRole('row'); + expect(within(rowsBefore[1]).getByText('testsketch1')).toBeInTheDocument(); + expect(within(rowsBefore[2]).getByText('testsketch2')).toBeInTheDocument(); + + expect( + screen.getByLabelText(/Sort by Date Created ascending/i) + ).toBeInTheDocument(); + act(() => { fireEvent.click(screen.getByText(/date created/i)); }); - const expectedAction = [{ type: 'TOGGLE_DIRECTION', field: 'createdAt' }]; - expect(store.getActions()).toEqual(expect.arrayContaining(expectedAction)); + expect( + screen.getByLabelText(/Sort by Date Created descending/i) + ).toBeInTheDocument(); + + const rowsAfter = screen.getAllByRole('row'); + expect(within(rowsAfter[1]).getByText('testsketch2')).toBeInTheDocument(); + expect(within(rowsAfter[2]).getByText('testsketch1')).toBeInTheDocument(); }); it('clicking on dropdown arrow opens sketch options - sketches belong to user', () => { diff --git a/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap b/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap index bd7475ebf9..7ac81971d6 100644 --- a/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap +++ b/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap @@ -12,53 +12,59 @@ exports[` snapshot testing 1`] = ` diff --git a/client/modules/IDE/reducers/sorting.js b/client/modules/IDE/reducers/sorting.js deleted file mode 100644 index 747d16c80a..0000000000 --- a/client/modules/IDE/reducers/sorting.js +++ /dev/null @@ -1,33 +0,0 @@ -import * as ActionTypes from '../../../constants'; -import { DIRECTION } from '../actions/sorting'; - -const initialState = { - field: 'createdAt', - direction: DIRECTION.DESC -}; - -const sorting = (state = initialState, action) => { - switch (action.type) { - case ActionTypes.TOGGLE_DIRECTION: - if (action.field && action.field !== state.field) { - if (action.field === 'name') { - return { ...state, field: action.field, direction: DIRECTION.ASC }; - } - return { ...state, field: action.field, direction: DIRECTION.DESC }; - } - if (state.direction === DIRECTION.ASC) { - return { ...state, direction: DIRECTION.DESC }; - } - return { ...state, direction: DIRECTION.ASC }; - case ActionTypes.SET_SORTING: - return { - ...state, - field: action.payload.field, - direction: action.payload.direction - }; - default: - return state; - } -}; - -export default sorting; diff --git a/client/modules/IDE/selectors/collections.js b/client/modules/IDE/selectors/collections.js index 207dce39a9..b1be7a20e6 100644 --- a/client/modules/IDE/selectors/collections.js +++ b/client/modules/IDE/selectors/collections.js @@ -1,12 +1,7 @@ import { createSelector } from 'reselect'; -import differenceInMilliseconds from 'date-fns/differenceInMilliseconds'; import find from 'lodash/find'; -import orderBy from 'lodash/orderBy'; -import { DIRECTION } from '../actions/sorting'; const getCollections = (state) => state.collections; -const getField = (state) => state.sorting.field; -const getDirection = (state) => state.sorting.direction; const getSearchTerm = (state) => state.search.collectionSearchTerm; const getFilteredCollections = createSelector( @@ -31,35 +26,8 @@ const getFilteredCollections = createSelector( } ); -const getSortedCollections = createSelector( - getFilteredCollections, - getField, - getDirection, - (collections, field, direction) => { - if (field === 'name') { - if (direction === DIRECTION.DESC) { - return orderBy(collections, 'name', 'desc'); - } - return orderBy(collections, 'name', 'asc'); - } else if (field === 'numItems') { - if (direction === DIRECTION.DESC) { - return orderBy(collections, 'items.length', 'desc'); - } - return orderBy(collections, 'items.length', 'asc'); - } - const sortedCollections = [...collections].sort((a, b) => { - const result = - direction === DIRECTION.ASC - ? differenceInMilliseconds(new Date(a[field]), new Date(b[field])) - : differenceInMilliseconds(new Date(b[field]), new Date(a[field])); - return result; - }); - return sortedCollections; - } -); - export function getCollection(state, id) { return find(getCollections(state), { id }); } -export default getSortedCollections; +export default getFilteredCollections; diff --git a/client/modules/IDE/selectors/projects.js b/client/modules/IDE/selectors/projects.js index 08701d211c..bb76ec1797 100644 --- a/client/modules/IDE/selectors/projects.js +++ b/client/modules/IDE/selectors/projects.js @@ -1,11 +1,6 @@ import { createSelector } from 'reselect'; -import differenceInMilliseconds from 'date-fns/differenceInMilliseconds'; -import orderBy from 'lodash/orderBy'; -import { DIRECTION } from '../actions/sorting'; const getSketches = (state) => state.sketches; -const getField = (state) => state.sorting.field; -const getDirection = (state) => state.sorting.direction; const getSearchTerm = (state) => state.search.sketchSearchTerm; const getFilteredSketches = createSelector( @@ -30,26 +25,4 @@ const getFilteredSketches = createSelector( } ); -const getSortedSketches = createSelector( - getFilteredSketches, - getField, - getDirection, - (sketches, field, direction) => { - if (field === 'name') { - if (direction === DIRECTION.DESC) { - return orderBy(sketches, 'name', 'desc'); - } - return orderBy(sketches, 'name', 'asc'); - } - const sortedSketches = [...sketches].sort((a, b) => { - const result = - direction === DIRECTION.ASC - ? differenceInMilliseconds(new Date(a[field]), new Date(b[field])) - : differenceInMilliseconds(new Date(b[field]), new Date(a[field])); - return result; - }); - return sortedSketches; - } -); - -export default getSortedSketches; +export default getFilteredSketches; diff --git a/client/modules/IDE/selectors/users.js b/client/modules/IDE/selectors/users.js index 4bda34ef11..bde1d238d6 100644 --- a/client/modules/IDE/selectors/users.js +++ b/client/modules/IDE/selectors/users.js @@ -6,6 +6,8 @@ const getTotalSize = (state) => state.user.totalSize; const getAssetsTotalSize = (state) => state.assets.totalSize; const getSketchOwner = (state) => state.project.owner; const getUserId = (state) => state.user.id; +export const selectCurrentUsername = (state) => state.user.username; + const limit = getConfig('UPLOAD_LIMIT') || 250000000; export const getCanUploadMedia = createSelector( diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index 7379bd36f1..313a5ccf1d 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -5,15 +5,13 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import { useTranslation, withTranslation } from 'react-i18next'; -import classNames from 'classnames'; import Button from '../../../common/Button'; import { DropdownArrowIcon } from '../../../common/icons'; +import TableBase from '../../../common/Table/TableBase'; import * as ProjectActions from '../../IDE/actions/project'; -import * as ProjectsActions from '../../IDE/actions/projects'; import * as CollectionsActions from '../../IDE/actions/collections'; -import * as ToastActions from '../../IDE/actions/toast'; -import * as SortingActions from '../../IDE/actions/sorting'; +import { DIRECTION } from '../../IDE/actions/sorting'; import * as IdeActions from '../../IDE/actions/ide'; import { getCollection } from '../../IDE/selectors/collections'; import Loader from '../../App/components/loader'; @@ -24,8 +22,6 @@ import CopyableInput from '../../IDE/components/CopyableInput'; import { SketchSearchbar } from '../../IDE/components/Searchbar'; import dates from '../../../utils/formatDate'; -import ArrowUpIcon from '../../../images/sort-arrow-up.svg'; -import ArrowDownIcon from '../../../images/sort-arrow-down.svg'; import RemoveIcon from '../../../images/close.svg'; const ShareURL = ({ value }) => { @@ -170,8 +166,6 @@ class Collection extends React.Component { constructor(props) { super(props); this.props.getCollections(this.props.username); - this.props.resetSorting(); - this._renderFieldHeader = this._renderFieldHeader.bind(this); this.showAddSketches = this.showAddSketches.bind(this); this.hideAddSketches = this.hideAddSketches.bind(this); @@ -335,86 +329,18 @@ class Collection extends React.Component { }); } - _renderEmptyTable() { - const isLoading = this.props.loading; - const hasCollectionItems = - this.props.collection != null && this.props.collection.items.length > 0; - - if (!isLoading && !hasCollectionItems) { - return ( -

- {this.props.t('Collection.NoSketches')} -

- ); - } - return null; - } - - _getButtonLabel = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - let buttonLabel; - if (field !== fieldName) { - if (field === 'name') { - buttonLabel = this.props.t('Collection.ButtonLabelAscendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('Collection.ButtonLabelDescendingARIA', { - displayName - }); - } - } else if (direction === SortingActions.DIRECTION.ASC) { - buttonLabel = this.props.t('Collection.ButtonLabelDescendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('Collection.ButtonLabelAscendingARIA', { - displayName - }); - } - return buttonLabel; - }; - - _renderFieldHeader(fieldName, displayName) { - const { field, direction } = this.props.sorting; - const headerClass = classNames({ - arrowDown: true, - 'sketches-table__header--selected': field === fieldName - }); - const buttonLabel = this._getButtonLabel(fieldName, displayName); - return ( - - - - ); - } - render() { const title = this.hasCollection() ? this.getCollectionName() : null; const isOwner = this.isOwner(); + // Need top-level string fields in order to sort. + const items = this.props.collection?.items?.map((item) => ({ + ...item, + // 'zz' is a dumb hack to put deleted items last in the sort order + name: item.isDeleted ? 'zz' : item.project?.name, + owner: item.isDeleted ? 'zz' : item.project?.user?.username + })); + return (
- {this._renderEmptyTable()} - {this.hasCollectionItems() && ( - - - - {this._renderFieldHeader( - 'name', - this.props.t('Collection.HeaderName') - )} - {this._renderFieldHeader( - 'createdAt', - this.props.t('Collection.HeaderCreatedAt') - )} - {this._renderFieldHeader( - 'user', - this.props.t('Collection.HeaderUser') - )} - - - - - {this.props.collection.items.map((item) => ( - - ))} - -
- )} + ( + + )} + /> {this.state.isAddingSketches && ( Date: Tue, 5 Sep 2023 12:33:36 -0500 Subject: [PATCH 2/4] update snapshot and fix lint warning --- client/common/Table/TableElements.jsx | 1 - .../SketchList.unit.test.jsx.snap | 60 ++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/client/common/Table/TableElements.jsx b/client/common/Table/TableElements.jsx index 83656c7488..59a8b88a0c 100644 --- a/client/common/Table/TableElements.jsx +++ b/client/common/Table/TableElements.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import styled from 'styled-components'; import { remSize } from '../../theme'; diff --git a/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap b/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap index 7ac81971d6..f908e91085 100644 --- a/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap +++ b/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap @@ -2,7 +2,51 @@ exports[` snapshot testing 1`] = ` -
snapshot testing 1`] = ` From 40fe65a43077870f173d704ea27ee6547fed3e01 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Fri, 24 Nov 2023 16:56:04 -0600 Subject: [PATCH 3/4] Fix merge conflicts by putting the sorting state back into Redux. --- client/common/Table/TableBase.jsx | 23 +++++----- client/common/Table/TableBase.test.jsx | 5 ++- client/constants.js | 3 ++ client/modules/IDE/actions/sorting.js | 21 +++++++++ .../IDE/components/AddToCollectionList.jsx | 4 +- .../components/AddToCollectionSketchList.jsx | 4 +- client/modules/IDE/components/AssetList.jsx | 4 +- .../CollectionList/CollectionList.jsx | 8 ++-- .../IDE/components/ConnectedTableBase.jsx | 43 +++++++++++++++++++ client/modules/IDE/components/SketchList.jsx | 9 ++-- client/modules/IDE/reducers/sorting.js | 33 ++++++++++++++ client/modules/User/components/Collection.jsx | 7 +-- client/reducers.js | 2 + 13 files changed, 134 insertions(+), 32 deletions(-) create mode 100644 client/modules/IDE/components/ConnectedTableBase.jsx create mode 100644 client/modules/IDE/reducers/sorting.js diff --git a/client/common/Table/TableBase.jsx b/client/common/Table/TableBase.jsx index fbdc5998a4..99c012cf2b 100644 --- a/client/common/Table/TableBase.jsx +++ b/client/common/Table/TableBase.jsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import { orderBy } from 'lodash'; import PropTypes from 'prop-types'; -import React, { useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import Loader from '../../modules/App/components/loader'; import { DIRECTION } from '../../modules/IDE/actions/sorting'; import { TableEmpty } from './TableElements'; @@ -11,11 +11,12 @@ const toAscDesc = (direction) => (direction === DIRECTION.ASC ? 'asc' : 'desc'); /** * Renders the headers, loading spinner, empty message. - * Applies sorting to the items. + * Sorts the array of items based on the `sortBy` prop. * Expects a `renderRow` prop to render each row. */ function TableBase({ - initialSort, + sortBy, + onChangeSort, columns, items = [], isLoading, @@ -25,11 +26,9 @@ function TableBase({ renderRow, className }) { - const [sorting, setSorting] = useState(initialSort); - const sortedItems = useMemo( - () => orderBy(items, sorting.field, toAscDesc(sorting.direction)), - [sorting.field, sorting.direction, items] + () => orderBy(items, sortBy.field, toAscDesc(sortBy.direction)), + [sortBy.field, sortBy.direction, items] ); if (isLoading) { @@ -51,8 +50,8 @@ function TableBase({ {columns.map((column) => ( ', () => { const props = { items, - initialSort: { field: 'count', direction: DIRECTION.DESC }, + sortBy: { field: 'count', direction: DIRECTION.DESC }, emptyMessage: 'No items found', renderRow: (item) => , - columns: [] + columns: [], + onChangeSort: jest.fn() }; const subject = (overrideProps) => diff --git a/client/constants.js b/client/constants.js index e0de41ef6a..565d716fa6 100644 --- a/client/constants.js +++ b/client/constants.js @@ -133,6 +133,9 @@ export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING'; export const SET_ASSETS = 'SET_ASSETS'; export const DELETE_ASSET = 'DELETE_ASSET'; +export const TOGGLE_DIRECTION = 'TOGGLE_DIRECTION'; +export const SET_SORTING = 'SET_SORTING'; +export const SET_SORT_PARAMS = 'SET_SORT_PARAMS'; export const SET_SEARCH_TERM = 'SET_SEARCH_TERM'; export const CLOSE_SKETCHLIST_MODAL = 'CLOSE_SKETCHLIST_MODAL'; diff --git a/client/modules/IDE/actions/sorting.js b/client/modules/IDE/actions/sorting.js index 07e040b896..b9aa0354cb 100644 --- a/client/modules/IDE/actions/sorting.js +++ b/client/modules/IDE/actions/sorting.js @@ -5,6 +5,27 @@ export const DIRECTION = { DESC: 'DESCENDING' }; +export function setSorting(field, direction) { + return { + type: ActionTypes.SET_SORTING, + payload: { + field, + direction + } + }; +} + +export function resetSorting() { + return setSorting('createdAt', DIRECTION.DESC); +} + +export function toggleDirectionForField(field) { + return { + type: ActionTypes.TOGGLE_DIRECTION, + field + }; +} + export function setSearchTerm(scope, searchTerm) { return { type: ActionTypes.SET_SEARCH_TERM, diff --git a/client/modules/IDE/components/AddToCollectionList.jsx b/client/modules/IDE/components/AddToCollectionList.jsx index 97f9e82c9c..b5c046b5fb 100644 --- a/client/modules/IDE/components/AddToCollectionList.jsx +++ b/client/modules/IDE/components/AddToCollectionList.jsx @@ -9,7 +9,7 @@ import * as ProjectActions from '../actions/project'; import * as ProjectsActions from '../actions/projects'; import * as CollectionsActions from '../actions/collections'; import * as ToastActions from '../actions/toast'; -import getSortedCollections from '../selectors/collections'; +import getFilteredCollections from '../selectors/collections'; import Loader from '../../App/components/loader'; import QuickAddList from './QuickAddList'; import { remSize } from '../../../theme'; @@ -168,7 +168,7 @@ CollectionList.defaultProps = { function mapStateToProps(state, ownProps) { return { user: state.user, - collections: getSortedCollections(state), + collections: getFilteredCollections(state), loading: state.loading, project: ownProps.project || state.project, projectId: ownProps && ownProps.params ? ownProps.prams.project_id : null diff --git a/client/modules/IDE/components/AddToCollectionSketchList.jsx b/client/modules/IDE/components/AddToCollectionSketchList.jsx index 0f9fe93499..3b575dfe72 100644 --- a/client/modules/IDE/components/AddToCollectionSketchList.jsx +++ b/client/modules/IDE/components/AddToCollectionSketchList.jsx @@ -8,7 +8,7 @@ import { withTranslation } from 'react-i18next'; import * as ProjectsActions from '../actions/projects'; import * as CollectionsActions from '../actions/collections'; import * as ToastActions from '../actions/toast'; -import getSortedSketches from '../selectors/projects'; +import getFilteredSketches from '../selectors/projects'; import Loader from '../../App/components/loader'; import QuickAddList from './QuickAddList'; import { @@ -135,7 +135,7 @@ SketchList.defaultProps = { function mapStateToProps(state) { return { user: state.user, - sketches: getSortedSketches(state), + sketches: getFilteredSketches(state), loading: state.loading, project: state.project }; diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index e7bb9d58a6..2339c636e4 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -5,10 +5,10 @@ import { Helmet } from 'react-helmet'; import { useTranslation } from 'react-i18next'; import { connect, useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import TableBase from '../../../common/Table/TableBase'; import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; import { deleteAssetRequest, getAssets } from '../actions/assets'; import { DIRECTION } from '../actions/sorting'; +import ConnectedTableBase from './ConnectedTableBase'; class AssetListRowBase extends React.Component { constructor(props) { @@ -173,7 +173,7 @@ const AssetList = () => { {t('AssetList.Title')} - { const { t } = useTranslation(); const dispatch = useDispatch(); - const collections = useSelector(getSortedCollections); + const collections = useSelector(getFilteredCollections); // TODO: combine with AddToCollectionList const loading = useSelector((state) => state.loading); @@ -50,7 +50,7 @@ const CollectionList = ({ username, mobile }) => { - { + const sorting = useSelector((state) => state.sorting); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(setSorting(initialSort.field, initialSort.direction)); + }, [initialSort.field, initialSort.direction, dispatch]); + + const handleSort = useCallback( + (sort) => { + dispatch(setSorting(sort.field, sort.direction)); + }, + [dispatch] + ); + + return ; +}; + +ConnectedTableBase.propTypes = { + ...omit(TableBase.propTypes, 'sortBy', 'onChangeSort'), + initialSort: TableBase.propTypes.sortBy +}; + +ConnectedTableBase.defaultProps = { + initialSort: { + field: 'createdAt', + direction: DIRECTION.DESC + } +}; + +export default ConnectedTableBase; diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index 0528eff49a..65ab571092 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -7,20 +7,20 @@ import { Link } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import slugify from 'slugify'; -import TableBase from '../../../common/Table/TableBase'; import dates from '../../../utils/formatDate'; import Overlay from '../../App/components/Overlay'; import * as IdeActions from '../actions/ide'; import * as ProjectActions from '../actions/project'; import { getProjects } from '../actions/projects'; import { DIRECTION } from '../actions/sorting'; -import getSortedSketches from '../selectors/projects'; +import getFilteredSketches from '../selectors/projects'; import { selectCurrentUsername } from '../selectors/users'; import AddToCollectionList from './AddToCollectionList'; import getConfig from '../../../utils/getConfig'; import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; import MoreIconSvg from '../../../images/more.svg'; +import ConnectedTableBase from './ConnectedTableBase'; const ROOT_URL = getConfig('API_URL'); @@ -371,7 +371,7 @@ const SketchList = ({ username, mobile }) => { const currentUser = useSelector(selectCurrentUsername); - const sketches = useSelector(getSortedSketches); + const sketches = useSelector(getFilteredSketches); // TODO: combine with AddToCollectionSketchList const loading = useSelector((state) => state.loading); @@ -395,7 +395,7 @@ const SketchList = ({ username, mobile }) => { })} - { }} emptyMessage={t('SketchList.NoSketches')} caption={t('SketchList.TableSummary')} - // TODO: figure out how to use the StandardTable -- needs dropdown and styling renderRow={(sketch) => ( { + switch (action.type) { + case ActionTypes.TOGGLE_DIRECTION: + if (action.field && action.field !== state.field) { + if (action.field === 'name') { + return { ...state, field: action.field, direction: DIRECTION.ASC }; + } + return { ...state, field: action.field, direction: DIRECTION.DESC }; + } + if (state.direction === DIRECTION.ASC) { + return { ...state, direction: DIRECTION.DESC }; + } + return { ...state, direction: DIRECTION.ASC }; + case ActionTypes.SET_SORTING: + return { + ...state, + field: action.payload.field, + direction: action.payload.direction + }; + default: + return state; + } +}; + +export default sorting; diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index 218fd949cb..d4d85026f2 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -6,17 +6,14 @@ import { Link } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import { useTranslation, withTranslation } from 'react-i18next'; -import Button from '../../../common/Button'; -import { DropdownArrowIcon } from '../../../common/icons'; -import TableBase from '../../../common/Table/TableBase'; import * as ProjectActions from '../../IDE/actions/project'; import * as CollectionsActions from '../../IDE/actions/collections'; import { DIRECTION } from '../../IDE/actions/sorting'; import * as IdeActions from '../../IDE/actions/ide'; +import ConnectedTableBase from '../../IDE/components/ConnectedTableBase'; import { getCollection } from '../../IDE/selectors/collections'; import Loader from '../../App/components/loader'; import dates from '../../../utils/formatDate'; - import RemoveIcon from '../../../images/close.svg'; import CollectionMetadata from './CollectionMetadata'; @@ -194,7 +191,7 @@ class Collection extends React.Component {
- Date: Tue, 19 Dec 2023 13:32:27 -0600 Subject: [PATCH 4/4] Fix visibility issue in Assets dropdown. --- client/components/Dropdown/DropdownMenu.jsx | 10 ++++++++-- client/components/Dropdown/TableDropdown.jsx | 1 - client/modules/User/components/Collection.jsx | 2 -- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/client/components/Dropdown/DropdownMenu.jsx b/client/components/Dropdown/DropdownMenu.jsx index da41b30101..8761cd606b 100644 --- a/client/components/Dropdown/DropdownMenu.jsx +++ b/client/components/Dropdown/DropdownMenu.jsx @@ -1,11 +1,17 @@ import PropTypes from 'prop-types'; import React, { forwardRef, useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; import useModalClose from '../../common/useModalClose'; import DownArrowIcon from '../../images/down-filled-triangle.svg'; import { DropdownWrapper } from '../Dropdown'; // TODO: enable arrow keys to navigate options from list +const Container = styled.div` + position: relative; + width: fit-content; +`; + const DropdownMenu = forwardRef( ( { children, anchor, 'aria-label': ariaLabel, align, className, classes }, @@ -38,7 +44,7 @@ const DropdownMenu = forwardRef( }; return ( -
+
+ ); } ); diff --git a/client/components/Dropdown/TableDropdown.jsx b/client/components/Dropdown/TableDropdown.jsx index d4db78f963..cbc8e3ccff 100644 --- a/client/components/Dropdown/TableDropdown.jsx +++ b/client/components/Dropdown/TableDropdown.jsx @@ -41,7 +41,6 @@ const TableDropdown = styled(DropdownMenu).attrs({ } & ul { top: 63%; - right: calc(100% - 26px); } `; diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index d4d85026f2..cefdb535aa 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -114,8 +114,6 @@ class Collection extends React.Component { constructor(props) { super(props); this.props.getCollections(this.props.username); - this.showAddSketches = this.showAddSketches.bind(this); - this.hideAddSketches = this.hideAddSketches.bind(this); } getTitle() {