From ac9d4d18b032b3c86cd4b4c576504fe76b6b0341 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sat, 17 Jun 2023 15:35:30 -0500 Subject: [PATCH 01/10] Duplicate files. --- .../User/components/CollectionMetadata.jsx | 553 ++++++++++++++++++ .../User/components/CollectionShareButton.jsx | 553 ++++++++++++++++++ 2 files changed, 1106 insertions(+) create mode 100644 client/modules/User/components/CollectionMetadata.jsx create mode 100644 client/modules/User/components/CollectionShareButton.jsx diff --git a/client/modules/User/components/CollectionMetadata.jsx b/client/modules/User/components/CollectionMetadata.jsx new file mode 100644 index 0000000000..563fd7d6ff --- /dev/null +++ b/client/modules/User/components/CollectionMetadata.jsx @@ -0,0 +1,553 @@ +import PropTypes from 'prop-types'; +import React, { useState, useRef, useEffect } from 'react'; +import { Helmet } from 'react-helmet'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +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 * 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 * as IdeActions from '../../IDE/actions/ide'; +import { getCollection } from '../../IDE/selectors/collections'; +import Loader from '../../App/components/loader'; +import EditableInput from '../../IDE/components/EditableInput'; +import Overlay from '../../App/components/Overlay'; +import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; +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 }) => { + const [showURL, setShowURL] = useState(false); + const node = useRef(); + const { t } = useTranslation(); + + const handleClickOutside = (e) => { + if (node.current.contains(e.target)) { + return; + } + setShowURL(false); + }; + + useEffect(() => { + if (showURL) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showURL]); + + return ( +
+ + {showURL && ( +
+ +
+ )} +
+ ); +}; + +ShareURL.propTypes = { + value: PropTypes.string.isRequired +}; + +const CollectionItemRowBase = ({ + collection, + item, + isOwner, + removeFromCollection +}) => { + const { t } = useTranslation(); + + const projectIsDeleted = item.isDeleted; + + const handleSketchRemove = () => { + const name = projectIsDeleted ? 'deleted sketch' : item.project.name; + + if ( + window.confirm( + t('Collection.DeleteFromCollection', { name_sketch: name }) + ) + ) { + removeFromCollection(collection.id, item.projectId); + } + }; + + const name = projectIsDeleted ? ( + {t('Collection.SketchDeleted')} + ) : ( + + {item.project.name} + + ); + + const sketchOwnerUsername = projectIsDeleted + ? null + : item.project.user.username; + + return ( + + {name} + {dates.format(item.createdAt)} + {sketchOwnerUsername} + + {isOwner && ( + + )} + + + ); +}; + +CollectionItemRowBase.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + item: PropTypes.shape({ + createdAt: PropTypes.string.isRequired, + projectId: PropTypes.string.isRequired, + isDeleted: PropTypes.bool.isRequired, + project: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string.isRequired + }) + }).isRequired + }).isRequired, + isOwner: PropTypes.bool.isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + removeFromCollection: PropTypes.func.isRequired +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators( + Object.assign({}, CollectionsActions, ProjectActions, IdeActions), + dispatch + ); +} + +const CollectionItemRow = connect( + null, + mapDispatchToPropsSketchListRow +)(CollectionItemRowBase); + +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); + + this.state = { + isAddingSketches: false + }; + } + + getTitle() { + if (this.props.username === this.props.user.username) { + return this.props.t('Collection.Title'); + } + return this.props.t('Collection.AnothersTitle', { + anotheruser: this.props.username + }); + } + + getUsername() { + return this.props.username !== undefined + ? this.props.username + : this.props.user.username; + } + + getCollectionName() { + return this.props.collection.name; + } + + isOwner() { + let isOwner = false; + + if ( + this.props.user != null && + this.props.user.username && + this.props.collection.owner.username === this.props.user.username + ) { + isOwner = true; + } + + return isOwner; + } + + hasCollection() { + return !this.props.loading && this.props.collection != null; + } + + hasCollectionItems() { + return this.hasCollection() && this.props.collection.items.length > 0; + } + + _renderLoader() { + if (this.props.loading) return ; + return null; + } + + _renderCollectionMetadata() { + const { id, name, description, items, owner } = this.props.collection; + + const hostname = window.location.origin; + const { username } = this.props; + + const baseURL = `${hostname}/${username}/collections/`; + + const handleEditCollectionName = (value) => { + if (value === name) { + return; + } + + this.props.editCollection(id, { name: value }); + }; + + const handleEditCollectionDescription = (value) => { + if (value === description) { + return; + } + + this.props.editCollection(id, { description: value }); + }; + + // + // TODO: Implement UI for editing slug + // + // const handleEditCollectionSlug = (value) => { + // if (value === slug) { + // return; + // } + // + // this.props.editCollection(id, { slug: value }); + // }; + + return ( +
+
+
+

+ {this.isOwner() ? ( + value !== ''} + /> + ) : ( + name + )} +

+ +

+ {this.isOwner() ? ( + + ) : ( + description + )} +

+ +

+ {this.props.t('Collection.By')} + + {owner.username} + +

+ +

+ {this.props.t('Collection.NumSketches', { count: items.length })} +

+
+ +
+

+ +

+ {this.isOwner() && ( + + )} +
+
+
+ ); + } + + showAddSketches() { + this.setState({ + isAddingSketches: true + }); + } + + hideAddSketches() { + this.setState({ + isAddingSketches: false + }); + } + + _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(); + + return ( +
+
+ + {this.getTitle()} + + {this._renderLoader()} + {this.hasCollection() && this._renderCollectionMetadata()} +
+
+ {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 && ( + } + closeOverlay={this.hideAddSketches} + isFixedHeight + > + + + )} +
+
+
+
+ ); + } +} + +Collection.propTypes = { + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + getCollections: PropTypes.func.isRequired, + collection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + slug: PropTypes.string, + description: PropTypes.string, + owner: PropTypes.shape({ + username: PropTypes.string + }).isRequired, + items: PropTypes.arrayOf(PropTypes.shape({})) + }), + username: PropTypes.string, + loading: PropTypes.bool.isRequired, + toggleDirectionForField: PropTypes.func.isRequired, + editCollection: PropTypes.func.isRequired, + resetSorting: PropTypes.func.isRequired, + sorting: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired, + t: PropTypes.func.isRequired +}; + +Collection.defaultProps = { + username: undefined, + collection: { + id: undefined, + items: [], + owner: { + username: undefined + } + } +}; + +function mapStateToProps(state, ownProps) { + return { + user: state.user, + collection: getCollection(state, ownProps.collectionId), + sorting: state.sorting, + loading: state.loading, + project: state.project + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators( + Object.assign( + {}, + CollectionsActions, + ProjectsActions, + ToastActions, + SortingActions + ), + dispatch + ); +} + +export default withTranslation()( + connect(mapStateToProps, mapDispatchToProps)(Collection) +); diff --git a/client/modules/User/components/CollectionShareButton.jsx b/client/modules/User/components/CollectionShareButton.jsx new file mode 100644 index 0000000000..563fd7d6ff --- /dev/null +++ b/client/modules/User/components/CollectionShareButton.jsx @@ -0,0 +1,553 @@ +import PropTypes from 'prop-types'; +import React, { useState, useRef, useEffect } from 'react'; +import { Helmet } from 'react-helmet'; +import { connect } from 'react-redux'; +import { Link } from 'react-router'; +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 * 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 * as IdeActions from '../../IDE/actions/ide'; +import { getCollection } from '../../IDE/selectors/collections'; +import Loader from '../../App/components/loader'; +import EditableInput from '../../IDE/components/EditableInput'; +import Overlay from '../../App/components/Overlay'; +import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; +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 }) => { + const [showURL, setShowURL] = useState(false); + const node = useRef(); + const { t } = useTranslation(); + + const handleClickOutside = (e) => { + if (node.current.contains(e.target)) { + return; + } + setShowURL(false); + }; + + useEffect(() => { + if (showURL) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showURL]); + + return ( +
+ + {showURL && ( +
+ +
+ )} +
+ ); +}; + +ShareURL.propTypes = { + value: PropTypes.string.isRequired +}; + +const CollectionItemRowBase = ({ + collection, + item, + isOwner, + removeFromCollection +}) => { + const { t } = useTranslation(); + + const projectIsDeleted = item.isDeleted; + + const handleSketchRemove = () => { + const name = projectIsDeleted ? 'deleted sketch' : item.project.name; + + if ( + window.confirm( + t('Collection.DeleteFromCollection', { name_sketch: name }) + ) + ) { + removeFromCollection(collection.id, item.projectId); + } + }; + + const name = projectIsDeleted ? ( + {t('Collection.SketchDeleted')} + ) : ( + + {item.project.name} + + ); + + const sketchOwnerUsername = projectIsDeleted + ? null + : item.project.user.username; + + return ( + + {name} + {dates.format(item.createdAt)} + {sketchOwnerUsername} + + {isOwner && ( + + )} + + + ); +}; + +CollectionItemRowBase.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + item: PropTypes.shape({ + createdAt: PropTypes.string.isRequired, + projectId: PropTypes.string.isRequired, + isDeleted: PropTypes.bool.isRequired, + project: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string.isRequired + }) + }).isRequired + }).isRequired, + isOwner: PropTypes.bool.isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + removeFromCollection: PropTypes.func.isRequired +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators( + Object.assign({}, CollectionsActions, ProjectActions, IdeActions), + dispatch + ); +} + +const CollectionItemRow = connect( + null, + mapDispatchToPropsSketchListRow +)(CollectionItemRowBase); + +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); + + this.state = { + isAddingSketches: false + }; + } + + getTitle() { + if (this.props.username === this.props.user.username) { + return this.props.t('Collection.Title'); + } + return this.props.t('Collection.AnothersTitle', { + anotheruser: this.props.username + }); + } + + getUsername() { + return this.props.username !== undefined + ? this.props.username + : this.props.user.username; + } + + getCollectionName() { + return this.props.collection.name; + } + + isOwner() { + let isOwner = false; + + if ( + this.props.user != null && + this.props.user.username && + this.props.collection.owner.username === this.props.user.username + ) { + isOwner = true; + } + + return isOwner; + } + + hasCollection() { + return !this.props.loading && this.props.collection != null; + } + + hasCollectionItems() { + return this.hasCollection() && this.props.collection.items.length > 0; + } + + _renderLoader() { + if (this.props.loading) return ; + return null; + } + + _renderCollectionMetadata() { + const { id, name, description, items, owner } = this.props.collection; + + const hostname = window.location.origin; + const { username } = this.props; + + const baseURL = `${hostname}/${username}/collections/`; + + const handleEditCollectionName = (value) => { + if (value === name) { + return; + } + + this.props.editCollection(id, { name: value }); + }; + + const handleEditCollectionDescription = (value) => { + if (value === description) { + return; + } + + this.props.editCollection(id, { description: value }); + }; + + // + // TODO: Implement UI for editing slug + // + // const handleEditCollectionSlug = (value) => { + // if (value === slug) { + // return; + // } + // + // this.props.editCollection(id, { slug: value }); + // }; + + return ( +
+
+
+

+ {this.isOwner() ? ( + value !== ''} + /> + ) : ( + name + )} +

+ +

+ {this.isOwner() ? ( + + ) : ( + description + )} +

+ +

+ {this.props.t('Collection.By')} + + {owner.username} + +

+ +

+ {this.props.t('Collection.NumSketches', { count: items.length })} +

+
+ +
+

+ +

+ {this.isOwner() && ( + + )} +
+
+
+ ); + } + + showAddSketches() { + this.setState({ + isAddingSketches: true + }); + } + + hideAddSketches() { + this.setState({ + isAddingSketches: false + }); + } + + _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(); + + return ( +
+
+ + {this.getTitle()} + + {this._renderLoader()} + {this.hasCollection() && this._renderCollectionMetadata()} +
+
+ {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 && ( + } + closeOverlay={this.hideAddSketches} + isFixedHeight + > + + + )} +
+
+
+
+ ); + } +} + +Collection.propTypes = { + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + getCollections: PropTypes.func.isRequired, + collection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + slug: PropTypes.string, + description: PropTypes.string, + owner: PropTypes.shape({ + username: PropTypes.string + }).isRequired, + items: PropTypes.arrayOf(PropTypes.shape({})) + }), + username: PropTypes.string, + loading: PropTypes.bool.isRequired, + toggleDirectionForField: PropTypes.func.isRequired, + editCollection: PropTypes.func.isRequired, + resetSorting: PropTypes.func.isRequired, + sorting: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired, + t: PropTypes.func.isRequired +}; + +Collection.defaultProps = { + username: undefined, + collection: { + id: undefined, + items: [], + owner: { + username: undefined + } + } +}; + +function mapStateToProps(state, ownProps) { + return { + user: state.user, + collection: getCollection(state, ownProps.collectionId), + sorting: state.sorting, + loading: state.loading, + project: state.project + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators( + Object.assign( + {}, + CollectionsActions, + ProjectsActions, + ToastActions, + SortingActions + ), + dispatch + ); +} + +export default withTranslation()( + connect(mapStateToProps, mapDispatchToProps)(Collection) +); From ccdae49558bdec6e31fdf3412ba3d84eaa05a00f Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sat, 17 Jun 2023 18:16:20 -0500 Subject: [PATCH 02/10] Extract `CollectionMetadata` into its own component and file. --- client/modules/User/components/Collection.jsx | 192 +----- .../User/components/CollectionMetadata.jsx | 615 +++--------------- .../User/components/CollectionShareButton.jsx | 507 +-------------- 3 files changed, 102 insertions(+), 1212 deletions(-) diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index 563fd7d6ff..0db510cfec 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect } from 'react'; +import React from 'react'; import { Helmet } from 'react-helmet'; import { connect } from 'react-redux'; import { Link } from 'react-router'; @@ -7,8 +7,6 @@ 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 * as ProjectActions from '../../IDE/actions/project'; import * as ProjectsActions from '../../IDE/actions/projects'; import * as CollectionsActions from '../../IDE/actions/collections'; @@ -17,61 +15,12 @@ import * as SortingActions from '../../IDE/actions/sorting'; import * as IdeActions from '../../IDE/actions/ide'; import { getCollection } from '../../IDE/selectors/collections'; import Loader from '../../App/components/loader'; -import EditableInput from '../../IDE/components/EditableInput'; -import Overlay from '../../App/components/Overlay'; -import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; -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 }) => { - const [showURL, setShowURL] = useState(false); - const node = useRef(); - const { t } = useTranslation(); - - const handleClickOutside = (e) => { - if (node.current.contains(e.target)) { - return; - } - setShowURL(false); - }; - - useEffect(() => { - if (showURL) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [showURL]); - - return ( -
- - {showURL && ( -
- -
- )} -
- ); -}; - -ShareURL.propTypes = { - value: PropTypes.string.isRequired -}; +import CollectionMetadata from './CollectionMetadata'; const CollectionItemRowBase = ({ collection, @@ -172,12 +121,6 @@ class Collection extends React.Component { 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); - - this.state = { - isAddingSketches: false - }; } getTitle() { @@ -195,10 +138,6 @@ class Collection extends React.Component { : this.props.user.username; } - getCollectionName() { - return this.props.collection.name; - } - isOwner() { let isOwner = false; @@ -226,115 +165,6 @@ class Collection extends React.Component { return null; } - _renderCollectionMetadata() { - const { id, name, description, items, owner } = this.props.collection; - - const hostname = window.location.origin; - const { username } = this.props; - - const baseURL = `${hostname}/${username}/collections/`; - - const handleEditCollectionName = (value) => { - if (value === name) { - return; - } - - this.props.editCollection(id, { name: value }); - }; - - const handleEditCollectionDescription = (value) => { - if (value === description) { - return; - } - - this.props.editCollection(id, { description: value }); - }; - - // - // TODO: Implement UI for editing slug - // - // const handleEditCollectionSlug = (value) => { - // if (value === slug) { - // return; - // } - // - // this.props.editCollection(id, { slug: value }); - // }; - - return ( -
-
-
-

- {this.isOwner() ? ( - value !== ''} - /> - ) : ( - name - )} -

- -

- {this.isOwner() ? ( - - ) : ( - description - )} -

- -

- {this.props.t('Collection.By')} - - {owner.username} - -

- -

- {this.props.t('Collection.NumSketches', { count: items.length })} -

-
- -
-

- -

- {this.isOwner() && ( - - )} -
-
-
- ); - } - - showAddSketches() { - this.setState({ - isAddingSketches: true - }); - } - - hideAddSketches() { - this.setState({ - isAddingSketches: false - }); - } - _renderEmptyTable() { const isLoading = this.props.loading; const hasCollectionItems = @@ -412,7 +242,6 @@ class Collection extends React.Component { } render() { - const title = this.hasCollection() ? this.getCollectionName() : null; const isOwner = this.isOwner(); return ( @@ -425,7 +254,7 @@ class Collection extends React.Component { {this.getTitle()} {this._renderLoader()} - {this.hasCollection() && this._renderCollectionMetadata()} +
{this._renderEmptyTable()} @@ -465,19 +294,6 @@ class Collection extends React.Component { )} - {this.state.isAddingSketches && ( - } - closeOverlay={this.hideAddSketches} - isFixedHeight - > - - - )}
@@ -487,6 +303,7 @@ class Collection extends React.Component { } Collection.propTypes = { + collectionId: PropTypes.string.isRequired, user: PropTypes.shape({ username: PropTypes.string, authenticated: PropTypes.bool.isRequired @@ -505,7 +322,6 @@ Collection.propTypes = { username: PropTypes.string, loading: PropTypes.bool.isRequired, toggleDirectionForField: PropTypes.func.isRequired, - editCollection: PropTypes.func.isRequired, resetSorting: PropTypes.func.isRequired, sorting: PropTypes.shape({ field: PropTypes.string.isRequired, diff --git a/client/modules/User/components/CollectionMetadata.jsx b/client/modules/User/components/CollectionMetadata.jsx index 563fd7d6ff..108a40850d 100644 --- a/client/modules/User/components/CollectionMetadata.jsx +++ b/client/modules/User/components/CollectionMetadata.jsx @@ -1,553 +1,126 @@ +import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect } from 'react'; -import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router'; -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 * 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 * as IdeActions from '../../IDE/actions/ide'; -import { getCollection } from '../../IDE/selectors/collections'; -import Loader from '../../App/components/loader'; -import EditableInput from '../../IDE/components/EditableInput'; import Overlay from '../../App/components/Overlay'; +import { editCollection } from '../../IDE/actions/collections'; import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; -import CopyableInput from '../../IDE/components/CopyableInput'; +import EditableInput from '../../IDE/components/EditableInput'; 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 }) => { - const [showURL, setShowURL] = useState(false); - const node = useRef(); - const { t } = useTranslation(); - - const handleClickOutside = (e) => { - if (node.current.contains(e.target)) { - return; - } - setShowURL(false); - }; - - useEffect(() => { - if (showURL) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [showURL]); - - return ( -
- - {showURL && ( -
- -
- )} -
- ); -}; - -ShareURL.propTypes = { - value: PropTypes.string.isRequired -}; +import { getCollection } from '../../IDE/selectors/collections'; +import ShareURL from './CollectionShareButton'; -const CollectionItemRowBase = ({ - collection, - item, - isOwner, - removeFromCollection -}) => { +function CollectionMetadata({ collectionId }) { const { t } = useTranslation(); - const projectIsDeleted = item.isDeleted; - - const handleSketchRemove = () => { - const name = projectIsDeleted ? 'deleted sketch' : item.project.name; - - if ( - window.confirm( - t('Collection.DeleteFromCollection', { name_sketch: name }) - ) - ) { - removeFromCollection(collection.id, item.projectId); - } - }; - - const name = projectIsDeleted ? ( - {t('Collection.SketchDeleted')} - ) : ( - - {item.project.name} - - ); - - const sketchOwnerUsername = projectIsDeleted - ? null - : item.project.user.username; - - return ( - - {name} - {dates.format(item.createdAt)} - {sketchOwnerUsername} - - {isOwner && ( - - )} - - - ); -}; + const dispatch = useDispatch(); -CollectionItemRowBase.propTypes = { - collection: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired - }).isRequired, - item: PropTypes.shape({ - createdAt: PropTypes.string.isRequired, - projectId: PropTypes.string.isRequired, - isDeleted: PropTypes.bool.isRequired, - project: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string.isRequired - }) - }).isRequired - }).isRequired, - isOwner: PropTypes.bool.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - removeFromCollection: PropTypes.func.isRequired -}; - -function mapDispatchToPropsSketchListRow(dispatch) { - return bindActionCreators( - Object.assign({}, CollectionsActions, ProjectActions, IdeActions), - dispatch - ); -} + const collection = useSelector((state) => getCollection(state, collectionId)); + const currentUsername = useSelector((state) => state.user.username); -const CollectionItemRow = connect( - null, - mapDispatchToPropsSketchListRow -)(CollectionItemRowBase); - -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); - - this.state = { - isAddingSketches: false - }; - } - - getTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('Collection.Title'); - } - return this.props.t('Collection.AnothersTitle', { - anotheruser: this.props.username - }); - } - - getUsername() { - return this.props.username !== undefined - ? this.props.username - : this.props.user.username; - } - - getCollectionName() { - return this.props.collection.name; - } - - isOwner() { - let isOwner = false; - - if ( - this.props.user != null && - this.props.user.username && - this.props.collection.owner.username === this.props.user.username - ) { - isOwner = true; - } + const [isAddingSketches, setIsAddingSketches] = useState(false); - return isOwner; - } - - hasCollection() { - return !this.props.loading && this.props.collection != null; - } - - hasCollectionItems() { - return this.hasCollection() && this.props.collection.items.length > 0; - } - - _renderLoader() { - if (this.props.loading) return ; + if (!collection) { return null; } - _renderCollectionMetadata() { - const { id, name, description, items, owner } = this.props.collection; - - const hostname = window.location.origin; - const { username } = this.props; - - const baseURL = `${hostname}/${username}/collections/`; - - const handleEditCollectionName = (value) => { - if (value === name) { - return; - } - - this.props.editCollection(id, { name: value }); - }; - - const handleEditCollectionDescription = (value) => { - if (value === description) { - return; - } - - this.props.editCollection(id, { description: value }); - }; - - // - // TODO: Implement UI for editing slug - // - // const handleEditCollectionSlug = (value) => { - // if (value === slug) { - // return; - // } - // - // this.props.editCollection(id, { slug: value }); - // }; - - return ( -
-
-
-

- {this.isOwner() ? ( - value !== ''} - /> - ) : ( - name - )} -

+ const { id, name, description, items, owner } = collection; + const { username } = owner; + const isOwner = !!currentUsername && currentUsername === username; -

- {this.isOwner() ? ( - - ) : ( - description - )} -

+ const hostname = window.location.origin; -

- {this.props.t('Collection.By')} - - {owner.username} - -

- -

- {this.props.t('Collection.NumSketches', { count: items.length })} -

-
- -
-

- -

- {this.isOwner() && ( - - )} -
-
-
- ); - } - - showAddSketches() { - this.setState({ - isAddingSketches: true - }); - } - - hideAddSketches() { - this.setState({ - isAddingSketches: false - }); - } - - _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')} -

- ); + const handleEditCollectionName = (value) => { + if (value === name) { + return; } - return null; - } + dispatch(editCollection(id, { name: value })); + }; - _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 - }); + const handleEditCollectionDescription = (value) => { + if (value === description) { + return; } - return buttonLabel; + dispatch(editCollection(id, { description: value })); }; - _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(); - - return ( -
-
- - {this.getTitle()} - - {this._renderLoader()} - {this.hasCollection() && this._renderCollectionMetadata()} -
-
- {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 && ( - } - closeOverlay={this.hideAddSketches} - isFixedHeight - > - - - )} -
-
-
-
- ); - } -} +

-Collection.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - getCollections: PropTypes.func.isRequired, - collection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - slug: PropTypes.string, - description: PropTypes.string, - owner: PropTypes.shape({ - username: PropTypes.string - }).isRequired, - items: PropTypes.arrayOf(PropTypes.shape({})) - }), - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - toggleDirectionForField: PropTypes.func.isRequired, - editCollection: PropTypes.func.isRequired, - resetSorting: PropTypes.func.isRequired, - sorting: PropTypes.shape({ - field: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired - }).isRequired, - t: PropTypes.func.isRequired -}; +

+ {t('Collection.By')} + {username} +

-Collection.defaultProps = { - username: undefined, - collection: { - id: undefined, - items: [], - owner: { - username: undefined - } - } -}; - -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collection: getCollection(state, ownProps.collectionId), - sorting: state.sorting, - loading: state.loading, - project: state.project - }; -} +

+ {t('Collection.NumSketches', { count: items.length })} +

+ -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ToastActions, - SortingActions - ), - dispatch +
+ + {isOwner && ( + + )} +
+ + {isAddingSketches && ( + } + closeOverlay={() => setIsAddingSketches(false)} + isFixedHeight + > + + + )} + ); } -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(Collection) -); +CollectionMetadata.propTypes = { + collectionId: PropTypes.string.isRequired +}; + +export default CollectionMetadata; diff --git a/client/modules/User/components/CollectionShareButton.jsx b/client/modules/User/components/CollectionShareButton.jsx index 563fd7d6ff..c4e0bba915 100644 --- a/client/modules/User/components/CollectionShareButton.jsx +++ b/client/modules/User/components/CollectionShareButton.jsx @@ -1,32 +1,10 @@ import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect } from 'react'; -import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; -import { Link } from 'react-router'; -import { bindActionCreators } from 'redux'; -import { useTranslation, withTranslation } from 'react-i18next'; -import classNames from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import Button from '../../../common/Button'; import { DropdownArrowIcon } from '../../../common/icons'; -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 * as IdeActions from '../../IDE/actions/ide'; -import { getCollection } from '../../IDE/selectors/collections'; -import Loader from '../../App/components/loader'; -import EditableInput from '../../IDE/components/EditableInput'; -import Overlay from '../../App/components/Overlay'; -import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; 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 }) => { const [showURL, setShowURL] = useState(false); @@ -34,7 +12,7 @@ const ShareURL = ({ value }) => { const { t } = useTranslation(); const handleClickOutside = (e) => { - if (node.current.contains(e.target)) { + if (node.current?.contains(e.target)) { return; } setShowURL(false); @@ -73,481 +51,4 @@ ShareURL.propTypes = { value: PropTypes.string.isRequired }; -const CollectionItemRowBase = ({ - collection, - item, - isOwner, - removeFromCollection -}) => { - const { t } = useTranslation(); - - const projectIsDeleted = item.isDeleted; - - const handleSketchRemove = () => { - const name = projectIsDeleted ? 'deleted sketch' : item.project.name; - - if ( - window.confirm( - t('Collection.DeleteFromCollection', { name_sketch: name }) - ) - ) { - removeFromCollection(collection.id, item.projectId); - } - }; - - const name = projectIsDeleted ? ( - {t('Collection.SketchDeleted')} - ) : ( - - {item.project.name} - - ); - - const sketchOwnerUsername = projectIsDeleted - ? null - : item.project.user.username; - - return ( - - {name} - {dates.format(item.createdAt)} - {sketchOwnerUsername} - - {isOwner && ( - - )} - - - ); -}; - -CollectionItemRowBase.propTypes = { - collection: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired - }).isRequired, - item: PropTypes.shape({ - createdAt: PropTypes.string.isRequired, - projectId: PropTypes.string.isRequired, - isDeleted: PropTypes.bool.isRequired, - project: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string.isRequired - }) - }).isRequired - }).isRequired, - isOwner: PropTypes.bool.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - removeFromCollection: PropTypes.func.isRequired -}; - -function mapDispatchToPropsSketchListRow(dispatch) { - return bindActionCreators( - Object.assign({}, CollectionsActions, ProjectActions, IdeActions), - dispatch - ); -} - -const CollectionItemRow = connect( - null, - mapDispatchToPropsSketchListRow -)(CollectionItemRowBase); - -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); - - this.state = { - isAddingSketches: false - }; - } - - getTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('Collection.Title'); - } - return this.props.t('Collection.AnothersTitle', { - anotheruser: this.props.username - }); - } - - getUsername() { - return this.props.username !== undefined - ? this.props.username - : this.props.user.username; - } - - getCollectionName() { - return this.props.collection.name; - } - - isOwner() { - let isOwner = false; - - if ( - this.props.user != null && - this.props.user.username && - this.props.collection.owner.username === this.props.user.username - ) { - isOwner = true; - } - - return isOwner; - } - - hasCollection() { - return !this.props.loading && this.props.collection != null; - } - - hasCollectionItems() { - return this.hasCollection() && this.props.collection.items.length > 0; - } - - _renderLoader() { - if (this.props.loading) return ; - return null; - } - - _renderCollectionMetadata() { - const { id, name, description, items, owner } = this.props.collection; - - const hostname = window.location.origin; - const { username } = this.props; - - const baseURL = `${hostname}/${username}/collections/`; - - const handleEditCollectionName = (value) => { - if (value === name) { - return; - } - - this.props.editCollection(id, { name: value }); - }; - - const handleEditCollectionDescription = (value) => { - if (value === description) { - return; - } - - this.props.editCollection(id, { description: value }); - }; - - // - // TODO: Implement UI for editing slug - // - // const handleEditCollectionSlug = (value) => { - // if (value === slug) { - // return; - // } - // - // this.props.editCollection(id, { slug: value }); - // }; - - return ( -
-
-
-

- {this.isOwner() ? ( - value !== ''} - /> - ) : ( - name - )} -

- -

- {this.isOwner() ? ( - - ) : ( - description - )} -

- -

- {this.props.t('Collection.By')} - - {owner.username} - -

- -

- {this.props.t('Collection.NumSketches', { count: items.length })} -

-
- -
-

- -

- {this.isOwner() && ( - - )} -
-
-
- ); - } - - showAddSketches() { - this.setState({ - isAddingSketches: true - }); - } - - hideAddSketches() { - this.setState({ - isAddingSketches: false - }); - } - - _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(); - - return ( -
-
- - {this.getTitle()} - - {this._renderLoader()} - {this.hasCollection() && this._renderCollectionMetadata()} -
-
- {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 && ( - } - closeOverlay={this.hideAddSketches} - isFixedHeight - > - - - )} -
-
-
-
- ); - } -} - -Collection.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - getCollections: PropTypes.func.isRequired, - collection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - slug: PropTypes.string, - description: PropTypes.string, - owner: PropTypes.shape({ - username: PropTypes.string - }).isRequired, - items: PropTypes.arrayOf(PropTypes.shape({})) - }), - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - toggleDirectionForField: PropTypes.func.isRequired, - editCollection: PropTypes.func.isRequired, - resetSorting: PropTypes.func.isRequired, - sorting: PropTypes.shape({ - field: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired - }).isRequired, - t: PropTypes.func.isRequired -}; - -Collection.defaultProps = { - username: undefined, - collection: { - id: undefined, - items: [], - owner: { - username: undefined - } - } -}; - -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collection: getCollection(state, ownProps.collectionId), - sorting: state.sorting, - loading: state.loading, - project: state.project - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(Collection) -); +export default ShareURL; From 9da55356bb0fbabd1e20b65b98ccd53a28c464d8 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sun, 16 Jul 2023 18:18:02 -0500 Subject: [PATCH 03/10] Extract common logic from modal component. --- client/common/useModalClose.js | 50 +++++++++++++++++++++++++ client/components/Nav/NavBar.jsx | 42 +++------------------ client/modules/IDE/components/Modal.jsx | 21 ++--------- client/modules/IDE/pages/IDEView.jsx | 10 ----- 4 files changed, 59 insertions(+), 64 deletions(-) create mode 100644 client/common/useModalClose.js diff --git a/client/common/useModalClose.js b/client/common/useModalClose.js new file mode 100644 index 0000000000..b0d36e89ff --- /dev/null +++ b/client/common/useModalClose.js @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react'; + +/** + * Common logic for Modal, Overlay, etc. + * + * Pass in the `onClose` handler. + * + * Can optionally pass in a ref, in case the `onClose` function needs to use the ref. + * + * Calls the provided `onClose` function on: + * - Press Escape key. + * - Click outside the element. + * + * Returns a ref to attach to the outermost element of the modal. + * + * @param {() => void} onClose + * @param {React.MutableRefObject} [passedRef] + * @return {React.MutableRefObject} + */ +export default function useModalClose(onClose, passedRef) { + const createdRef = useRef(null); + const modalRef = passedRef || createdRef; + + useEffect(() => { + modalRef.current?.focus(); + + function handleKeyDown(e) { + if (e.key === 'Escape') { + onClose?.(); + } + } + + function handleClick(e) { + // ignore clicks on the component itself + if (modalRef.current && !modalRef.current.contains(e.target)) { + onClose?.(); + } + } + + document.addEventListener('mousedown', handleClick, false); + document.addEventListener('keydown', handleKeyDown, false); + + return () => { + document.removeEventListener('mousedown', handleClick, false); + document.removeEventListener('keydown', handleKeyDown, false); + }; + }, [onClose, modalRef]); + + return modalRef; +} diff --git a/client/components/Nav/NavBar.jsx b/client/components/Nav/NavBar.jsx index d5e33ada23..8452138044 100644 --- a/client/components/Nav/NavBar.jsx +++ b/client/components/Nav/NavBar.jsx @@ -1,11 +1,6 @@ import PropTypes from 'prop-types'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState -} from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import useModalClose from '../../common/useModalClose'; import { MenuOpenContext, NavBarContext } from './contexts'; function NavBar({ children }) { @@ -13,37 +8,12 @@ function NavBar({ children }) { const timerRef = useRef(null); - const nodeRef = useRef(null); - - useEffect(() => { - function handleClick(e) { - if (!nodeRef.current) { - return; - } - if (nodeRef.current.contains(e.target)) { - return; - } - setDropdownOpen('none'); - } - document.addEventListener('mousedown', handleClick, false); - return () => { - document.removeEventListener('mousedown', handleClick, false); - }; - }, [nodeRef, setDropdownOpen]); - - // TODO: replace with `useKeyDownHandlers` after #2052 is merged - useEffect(() => { - function handleKeyDown(e) { - if (e.keyCode === 27) { - setDropdownOpen('none'); - } - } - document.addEventListener('keydown', handleKeyDown, false); - return () => { - document.removeEventListener('keydown', handleKeyDown, false); - }; + const handleClose = useCallback(() => { + setDropdownOpen('none'); }, [setDropdownOpen]); + const nodeRef = useModalClose(handleClose); + const clearHideTimeout = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); diff --git a/client/modules/IDE/components/Modal.jsx b/client/modules/IDE/components/Modal.jsx index 5d1bbc88e0..831527b266 100644 --- a/client/modules/IDE/components/Modal.jsx +++ b/client/modules/IDE/components/Modal.jsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, { useEffect, useRef } from 'react'; +import React from 'react'; +import useModalClose from '../../../common/useModalClose'; import ExitIcon from '../../../images/exit.svg'; // Common logic from NewFolderModal, NewFileModal, UploadFileModal @@ -12,23 +13,7 @@ const Modal = ({ contentClassName, children }) => { - const modalRef = useRef(null); - - const handleOutsideClick = (e) => { - // ignore clicks on the component itself - if (modalRef.current?.contains?.(e.target)) return; - - onClose(); - }; - - useEffect(() => { - modalRef.current?.focus(); - document.addEventListener('click', handleOutsideClick, false); - - return () => { - document.removeEventListener('click', handleOutsideClick, false); - }; - }, []); + const modalRef = useModalClose(onClose); return (
diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 5158e33c44..d10a7f24f6 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -220,14 +220,6 @@ class IDEView extends React.Component { } else { this.props.expandConsole(); } - } else if (e.keyCode === 27) { - if (this.props.ide.newFolderModalVisible) { - this.props.closeNewFolderModal(); - } else if (this.props.ide.uploadFileModalVisible) { - this.props.closeUploadFileModal(); - } else if (this.props.ide.modalIsVisible) { - this.props.closeNewFileModal(); - } } } @@ -556,8 +548,6 @@ IDEView.propTypes = { openProjectOptions: PropTypes.func.isRequired, closeProjectOptions: PropTypes.func.isRequired, newFolder: PropTypes.func.isRequired, - closeNewFolderModal: PropTypes.func.isRequired, - closeNewFileModal: PropTypes.func.isRequired, closeShareModal: PropTypes.func.isRequired, closeKeyboardShortcutModal: PropTypes.func.isRequired, autosaveProject: PropTypes.func.isRequired, From 0854d43a36e43e8170d837a8021dfa1fb78968c3 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sun, 16 Jul 2023 18:19:47 -0500 Subject: [PATCH 04/10] Convert `Overlay` to a function component. --- client/modules/App/components/Overlay.jsx | 143 +++++++++------------- 1 file changed, 56 insertions(+), 87 deletions(-) diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index 39f492fc6f..6759beb5ab 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -1,101 +1,73 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback, useRef } from 'react'; +import { useSelector } from 'react-redux'; import { browserHistory } from 'react-router'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; +import useModalClose from '../../../common/useModalClose'; import ExitIcon from '../../../images/exit.svg'; -class Overlay extends React.Component { - constructor(props) { - super(props); - this.close = this.close.bind(this); - this.handleClick = this.handleClick.bind(this); - this.handleClickOutside = this.handleClickOutside.bind(this); - this.keyPressHandle = this.keyPressHandle.bind(this); - } +const Overlay = ({ + actions, + ariaLabel, + children, + closeOverlay, + isFixedHeight, + title +}) => { + const { t } = useTranslation(); - componentWillMount() { - document.addEventListener('mousedown', this.handleClick, false); - document.addEventListener('keydown', this.keyPressHandle); - } + const previousPath = useSelector((state) => state.ide.previousPath); - componentDidMount() { - this.node.focus(); - } + const ref = useRef(null); - componentWillUnmount() { - document.removeEventListener('mousedown', this.handleClick, false); - document.removeEventListener('keydown', this.keyPressHandle); - } - - handleClick(e) { - if (this.node.contains(e.target)) { - return; - } - - this.handleClickOutside(e); - } - - handleClickOutside() { - this.close(); - } - - keyPressHandle(e) { - // escape key code = 27. - // So here we are checking if the key pressed was Escape key. - if (e.keyCode === 27) { - this.close(); - } - } - - close() { + const close = useCallback(() => { + const node = ref.current; + if (!node) return; // Only close if it is the last (and therefore the topmost overlay) const overlays = document.getElementsByClassName('overlay'); - if (this.node.parentElement.parentElement !== overlays[overlays.length - 1]) + if (node.parentElement.parentElement !== overlays[overlays.length - 1]) return; - if (!this.props.closeOverlay) { - browserHistory.push(this.props.previousPath); + if (!closeOverlay) { + browserHistory.push(previousPath); } else { - this.props.closeOverlay(); + closeOverlay(); } - } + }, [previousPath, closeOverlay, ref]); - render() { - const { ariaLabel, title, children, actions, isFixedHeight } = this.props; - return ( -
-
-
{ - this.node = node; - }} - className="overlay__body" - > -
-

{title}

-
- {actions} - -
-
- {children} -
-
+ useModalClose(close, ref); + + return ( +
+
+
+
+

{title}

+
+ {actions} + +
+
+ {children} +
- ); - } -} +
+ ); +}; Overlay.propTypes = { children: PropTypes.element, @@ -103,9 +75,7 @@ Overlay.propTypes = { closeOverlay: PropTypes.func, title: PropTypes.string, ariaLabel: PropTypes.string, - previousPath: PropTypes.string, - isFixedHeight: PropTypes.bool, - t: PropTypes.func.isRequired + isFixedHeight: PropTypes.bool }; Overlay.defaultProps = { @@ -114,8 +84,7 @@ Overlay.defaultProps = { title: 'Modal', closeOverlay: null, ariaLabel: 'modal', - previousPath: '/', isFixedHeight: false }; -export default withTranslation()(Overlay); +export default Overlay; From 65823629e1e1ea81c70b2e26ab728e4bf118a4a8 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sun, 16 Jul 2023 18:32:59 -0500 Subject: [PATCH 05/10] Apply `useModalClose` to collection `ShareURL`. --- client/modules/User/components/Collection.jsx | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index 563fd7d6ff..196489c3e8 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useCallback } from 'react'; import { Helmet } from 'react-helmet'; import { connect } from 'react-redux'; import { Link } from 'react-router'; @@ -9,6 +9,7 @@ import classNames from 'classnames'; import Button from '../../../common/Button'; import { DropdownArrowIcon } from '../../../common/icons'; +import useModalClose from '../../../common/useModalClose'; import * as ProjectActions from '../../IDE/actions/project'; import * as ProjectsActions from '../../IDE/actions/projects'; import * as CollectionsActions from '../../IDE/actions/collections'; @@ -30,30 +31,12 @@ import RemoveIcon from '../../../images/close.svg'; const ShareURL = ({ value }) => { const [showURL, setShowURL] = useState(false); - const node = useRef(); const { t } = useTranslation(); - - const handleClickOutside = (e) => { - if (node.current.contains(e.target)) { - return; - } - setShowURL(false); - }; - - useEffect(() => { - if (showURL) { - document.addEventListener('mousedown', handleClickOutside); - } else { - document.removeEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [showURL]); + const close = useCallback(() => setShowURL(false), [setShowURL]); + const ref = useModalClose(close); return ( -
+
); - } - - render() { - const isOwner = this.isOwner(); + }; - return ( -
-
- - {this.getTitle()} - - {this._renderLoader()} - -
-
- {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) => ( - - ))} - -
- )} -
-
+ return ( +
+
+ + + {isOwner + ? t('Collection.Title') + : t('Collection.AnothersTitle', { + anotheruser: username + })} + + + {showLoader && } + {collection && ( + + )} +
+
+ {hasZeroItems && ( +

+ {t('Collection.NoSketches')} +

+ )} + {hasCollectionItems && ( + + + + {_renderFieldHeader('name', t('Collection.HeaderName'))} + {_renderFieldHeader( + 'createdAt', + t('Collection.HeaderCreatedAt') + )} + {_renderFieldHeader('user', t('Collection.HeaderUser'))} + + + + + {collection.items.map((item) => ( + + ))} + +
+ )} +
-
- ); - } -} +
+
+ ); +}; Collection.propTypes = { collectionId: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - getCollections: PropTypes.func.isRequired, - collection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - slug: PropTypes.string, - description: PropTypes.string, - owner: PropTypes.shape({ - username: PropTypes.string - }).isRequired, - items: PropTypes.arrayOf(PropTypes.shape({})) - }), - 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, - t: PropTypes.func.isRequired -}; - -Collection.defaultProps = { - username: undefined, - collection: { - id: undefined, - items: [], - owner: { - username: undefined - } - } + username: PropTypes.string.isRequired }; -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collection: getCollection(state, ownProps.collectionId), - sorting: state.sorting, - loading: state.loading, - project: state.project - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(Collection) -); +export default Collection; diff --git a/client/modules/User/components/CollectionMetadata.jsx b/client/modules/User/components/CollectionMetadata.jsx index 108a40850d..9d6df781d2 100644 --- a/client/modules/User/components/CollectionMetadata.jsx +++ b/client/modules/User/components/CollectionMetadata.jsx @@ -3,33 +3,26 @@ import PropTypes from 'prop-types'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { Link } from 'react-router'; +import { Link } from 'react-router-dom'; import Button from '../../../common/Button'; import Overlay from '../../App/components/Overlay'; import { editCollection } from '../../IDE/actions/collections'; import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; import EditableInput from '../../IDE/components/EditableInput'; import { SketchSearchbar } from '../../IDE/components/Searchbar'; -import { getCollection } from '../../IDE/selectors/collections'; import ShareURL from './CollectionShareButton'; -function CollectionMetadata({ collectionId }) { +function CollectionMetadata({ collection, isOwner }) { const { t } = useTranslation(); const dispatch = useDispatch(); - const collection = useSelector((state) => getCollection(state, collectionId)); const currentUsername = useSelector((state) => state.user.username); const [isAddingSketches, setIsAddingSketches] = useState(false); - if (!collection) { - return null; - } - const { id, name, description, items, owner } = collection; - const { username } = owner; - const isOwner = !!currentUsername && currentUsername === username; + const { username: ownerUsername } = owner; const hostname = window.location.origin; @@ -85,7 +78,7 @@ function CollectionMetadata({ collectionId }) {

{t('Collection.By')} - {username} + {ownerUsername}

@@ -94,7 +87,7 @@ function CollectionMetadata({ collectionId }) {

- + {isOwner && ( + + ); +}; + +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: Mon, 14 Aug 2023 11:59:59 -0500 Subject: [PATCH 09/10] Common dropdown component for table actions. --- client/components/Dropdown.jsx | 3 +- client/components/Dropdown/DropdownMenu.jsx | 83 ++++++++ client/components/Dropdown/MenuItem.jsx | 35 ++++ client/components/Dropdown/TableDropdown.jsx | 20 ++ client/modules/IDE/components/AssetList.jsx | 161 +++++---------- .../CollectionList/CollectionListRow.jsx | 119 ++--------- client/modules/IDE/components/SketchList.jsx | 193 ++++-------------- .../SketchList.unit.test.jsx.snap | 40 ++-- client/styles/components/_asset-list.scss | 21 -- client/styles/components/_sketch-list.scss | 17 -- 10 files changed, 265 insertions(+), 427 deletions(-) create mode 100644 client/components/Dropdown/DropdownMenu.jsx create mode 100644 client/components/Dropdown/MenuItem.jsx create mode 100644 client/components/Dropdown/TableDropdown.jsx diff --git a/client/components/Dropdown.jsx b/client/components/Dropdown.jsx index 9a91d54cd2..369bef51b0 100644 --- a/client/components/Dropdown.jsx +++ b/client/components/Dropdown.jsx @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { remSize, prop } from '../theme'; import IconButton from './mobile/IconButton'; -const DropdownWrapper = styled.ul` +export const DropdownWrapper = styled.ul` background-color: ${prop('Modal.background')}; border: 1px solid ${prop('Modal.border')}; box-shadow: 0 0 18px 0 ${prop('shadowColor')}; @@ -52,6 +52,7 @@ const DropdownWrapper = styled.ul` & button span, & a { padding: ${remSize(8)} ${remSize(16)}; + font-size: ${remSize(12)}; } * { diff --git a/client/components/Dropdown/DropdownMenu.jsx b/client/components/Dropdown/DropdownMenu.jsx new file mode 100644 index 0000000000..ce7e03837e --- /dev/null +++ b/client/components/Dropdown/DropdownMenu.jsx @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { forwardRef, useCallback, useRef, useState } from 'react'; +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 DropdownMenu = forwardRef( + ({ children, 'aria-label': ariaLabel, align, className, classes }, ref) => { + // Note: need to use a ref instead of a state to avoid stale closures. + const focusedRef = useRef(false); + + const [isOpen, setIsOpen] = useState(false); + + const close = useCallback(() => setIsOpen(false), [setIsOpen]); + + const anchorRef = useModalClose(close, ref); + + const toggle = useCallback(() => { + setIsOpen((prevState) => !prevState); + }, [setIsOpen]); + + const handleFocus = () => { + focusedRef.current = true; + }; + + const handleBlur = () => { + focusedRef.current = false; + setTimeout(() => { + if (!focusedRef.current) { + close(); + } + }, 200); + }; + + return ( +
+ + {isOpen && ( + + {children} + + )} +
+ ); + } +); + +DropdownMenu.propTypes = { + children: PropTypes.node, + 'aria-label': PropTypes.string.isRequired, + align: PropTypes.oneOf(['left', 'right']), + className: PropTypes.string, + classes: PropTypes.shape({ + button: PropTypes.string, + list: PropTypes.string + }) +}; + +DropdownMenu.defaultProps = { + children: null, + align: 'right', + className: '', + classes: {} +}; + +export default DropdownMenu; diff --git a/client/components/Dropdown/MenuItem.jsx b/client/components/Dropdown/MenuItem.jsx new file mode 100644 index 0000000000..8b6f6d7247 --- /dev/null +++ b/client/components/Dropdown/MenuItem.jsx @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ButtonOrLink from '../../common/ButtonOrLink'; + +// TODO: combine with NavMenuItem + +function MenuItem({ hideIf, ...rest }) { + if (hideIf) { + return null; + } + + return ( +
  • + +
  • + ); +} + +MenuItem.propTypes = { + ...ButtonOrLink.propTypes, + onClick: PropTypes.func, + value: PropTypes.string, + /** + * Provides a way to deal with optional items. + */ + hideIf: PropTypes.bool +}; + +MenuItem.defaultProps = { + onClick: null, + value: null, + hideIf: false +}; + +export default MenuItem; diff --git a/client/components/Dropdown/TableDropdown.jsx b/client/components/Dropdown/TableDropdown.jsx new file mode 100644 index 0000000000..da7b6d7342 --- /dev/null +++ b/client/components/Dropdown/TableDropdown.jsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; +import { prop, remSize } from '../../theme'; +import DropdownMenu from './DropdownMenu'; + +const TableDropdown = styled(DropdownMenu).attrs({ align: 'right' })` + & > button { + width: ${remSize(25)}; + height: ${remSize(25)}; + & polygon, + & path { + fill: ${prop('inactiveTextColor')}; + } + } + & ul { + top: 63%; + right: calc(100% - 26px); + } +`; + +export default TableDropdown; diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index 559f60c580..7da5c0e865 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -1,129 +1,68 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { bindActionCreators } from 'redux'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import prettyBytes from 'pretty-bytes'; -import { withTranslation } from 'react-i18next'; +import { useTranslation, withTranslation } from 'react-i18next'; +import MenuItem from '../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../components/Dropdown/TableDropdown'; import Loader from '../../App/components/loader'; +import { deleteAssetRequest } from '../actions/assets'; import * as AssetActions from '../actions/assets'; -import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; -class AssetListRowBase extends React.Component { - constructor(props) { - super(props); - this.state = { - isFocused: false, - optionsOpen: false - }; - } - - onFocusComponent = () => { - this.setState({ isFocused: true }); - }; - - onBlurComponent = () => { - this.setState({ isFocused: false }); - setTimeout(() => { - if (!this.state.isFocused) { - this.closeOptions(); - } - }, 200); - }; - - openOptions = () => { - this.setState({ - optionsOpen: true - }); - }; +const AssetMenu = ({ item: asset }) => { + const { t } = useTranslation(); - closeOptions = () => { - this.setState({ - optionsOpen: false - }); - }; + const dispatch = useDispatch(); - toggleOptions = () => { - if (this.state.optionsOpen) { - this.closeOptions(); - } else { - this.openOptions(); + const handleAssetDelete = () => { + const { key, name } = asset; + if (window.confirm(t('Common.DeleteConfirmation', { name }))) { + dispatch(deleteAssetRequest(key)); } }; - handleDropdownOpen = () => { - this.closeOptions(); - this.openOptions(); - }; + return ( + + {t('AssetList.Delete')} + + {t('AssetList.OpenNewTab')} + + + ); +}; - handleAssetDelete = () => { - const { key, name } = this.props.asset; - this.closeOptions(); - if (window.confirm(this.props.t('Common.DeleteConfirmation', { name }))) { - this.props.deleteAssetRequest(key); - } - }; +AssetMenu.propTypes = { + item: PropTypes.shape({ + key: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired +}; - render() { - const { asset, username, t } = this.props; - const { optionsOpen } = this.state; - return ( - - - - {asset.name} - - - {prettyBytes(asset.size)} - - {asset.sketchId && ( - - {asset.sketchName} - - )} - - - - {optionsOpen && ( -
      -
    • - -
    • -
    • - - {t('AssetList.OpenNewTab')} - -
    • -
    - )} - - - ); - } -} +const AssetListRowBase = ({ asset, username }) => ( + + + + {asset.name} + + + {prettyBytes(asset.size)} + + {asset.sketchId && ( + + {asset.sketchName} + + )} + + + + + +); AssetListRowBase.propTypes = { asset: PropTypes.shape({ @@ -134,9 +73,7 @@ AssetListRowBase.propTypes = { name: PropTypes.string.isRequired, size: PropTypes.number.isRequired }).isRequired, - deleteAssetRequest: PropTypes.func.isRequired, - username: PropTypes.string.isRequired, - t: PropTypes.func.isRequired + username: PropTypes.string.isRequired }; function mapStateToPropsAssetListRow(state) { diff --git a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index ed109141d7..ffb2d3c3d2 100644 --- a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx @@ -4,84 +4,35 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import { withTranslation } from 'react-i18next'; +import MenuItem from '../../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../../components/Dropdown/TableDropdown'; import * as ProjectActions from '../../actions/project'; import * as CollectionsActions from '../../actions/collections'; import * as IdeActions from '../../actions/ide'; import * as ToastActions from '../../actions/toast'; import dates from '../../../../utils/formatDate'; -import DownFilledTriangleIcon from '../../../../images/down-filled-triangle.svg'; - class CollectionListRowBase extends React.Component { - static projectInCollection(project, collection) { - return ( - collection.items.find((item) => item.project.id === project.id) != null - ); - } - constructor(props) { super(props); this.state = { - optionsOpen: false, - isFocused: false, renameOpen: false, renameValue: '' }; this.renameInput = React.createRef(); } - onFocusComponent = () => { - this.setState({ isFocused: true }); - }; - - onBlurComponent = () => { - this.setState({ isFocused: false }); - setTimeout(() => { - if (!this.state.isFocused) { - this.closeAll(); - } - }, 200); - }; - - openOptions = () => { - this.setState({ - optionsOpen: true - }); - }; - - closeOptions = () => { - this.setState({ - optionsOpen: false - }); - }; - - toggleOptions = () => { - if (this.state.optionsOpen) { - this.closeOptions(); - } else { - this.openOptions(); - } - }; - closeAll = () => { this.setState({ - optionsOpen: false, renameOpen: false }); }; handleAddSketches = () => { - this.closeAll(); this.props.onAddSketches(); }; - handleDropdownOpen = () => { - this.closeAll(); - this.openOptions(); - }; - handleCollectionDelete = () => { - this.closeAll(); if ( window.confirm( this.props.t('Common.DeleteConfirmation', { @@ -94,7 +45,6 @@ class CollectionListRowBase extends React.Component { }; handleRenameOpen = () => { - this.closeAll(); this.setState( { renameOpen: true, @@ -132,61 +82,24 @@ class CollectionListRowBase extends React.Component { }; renderActions = () => { - const { optionsOpen } = this.state; const userIsOwner = this.props.user.username === this.props.username; return ( - - - {optionsOpen && ( -
      -
    • - -
    • - {userIsOwner && ( -
    • - -
    • - )} - {userIsOwner && ( -
    • - -
    • - )} -
    + + > + + {this.props.t('CollectionListRow.AddSketch')} + + + {this.props.t('CollectionListRow.Delete')} + + + {this.props.t('CollectionListRow.Rename')} + + ); }; diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index af03453e0d..108e00755e 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -7,6 +7,8 @@ import { Link } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import classNames from 'classnames'; import slugify from 'slugify'; +import MenuItem from '../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../components/Dropdown/TableDropdown'; import dates from '../../../utils/formatDate'; import * as ProjectActions from '../actions/project'; import * as ProjectsActions from '../actions/projects'; @@ -22,7 +24,6 @@ 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'); @@ -33,47 +34,12 @@ class SketchListRowBase extends React.Component { constructor(props) { super(props); this.state = { - optionsOpen: false, renameOpen: false, - renameValue: props.sketch.name, - isFocused: false + renameValue: props.sketch.name }; this.renameInput = React.createRef(); } - onFocusComponent = () => { - this.setState({ isFocused: true }); - }; - - onBlurComponent = () => { - this.setState({ isFocused: false }); - setTimeout(() => { - if (!this.state.isFocused) { - this.closeAll(); - } - }, 200); - }; - - openOptions = () => { - this.setState({ - optionsOpen: true - }); - }; - - closeOptions = () => { - this.setState({ - optionsOpen: false - }); - }; - - toggleOptions = () => { - if (this.state.optionsOpen) { - this.closeOptions(); - } else { - this.openOptions(); - } - }; - openRename = () => { this.setState( { @@ -90,13 +56,6 @@ class SketchListRowBase extends React.Component { }); }; - closeAll = () => { - this.setState({ - renameOpen: false, - optionsOpen: false - }); - }; - handleRenameChange = (e) => { this.setState({ renameValue: e.target.value @@ -106,13 +65,13 @@ class SketchListRowBase extends React.Component { handleRenameEnter = (e) => { if (e.key === 'Enter') { this.updateName(); - this.closeAll(); + this.closeRename(); } }; handleRenameBlur = () => { this.updateName(); - this.closeAll(); + this.closeRename(); }; updateName = () => { @@ -125,23 +84,6 @@ class SketchListRowBase extends React.Component { } }; - resetSketchName = () => { - this.setState({ - renameValue: this.props.sketch.name, - renameOpen: false - }); - }; - - handleDropdownOpen = () => { - this.closeAll(); - this.openOptions(); - }; - - handleRenameOpen = () => { - this.closeAll(); - this.openRename(); - }; - handleSketchDownload = () => { const { sketch } = this.props; const downloadLink = document.createElement('a'); @@ -153,12 +95,10 @@ class SketchListRowBase extends React.Component { }; handleSketchDuplicate = () => { - this.closeAll(); this.props.cloneProject(this.props.sketch); }; handleSketchShare = () => { - this.closeAll(); this.props.showShareModal( this.props.sketch.id, this.props.sketch.name, @@ -167,7 +107,6 @@ class SketchListRowBase extends React.Component { }; handleSketchDelete = () => { - this.closeAll(); if ( window.confirm( this.props.t('Common.DeleteConfirmation', { @@ -179,102 +118,42 @@ class SketchListRowBase extends React.Component { } }; - renderViewButton = (sketchURL) => ( - - {this.props.t('SketchList.View')} - - ); - renderDropdown = () => { - const { optionsOpen } = this.state; const userIsOwner = this.props.user.username === this.props.username; return ( - - {optionsOpen && ( -
      - {userIsOwner && ( -
    • - -
    • - )} -
    • - -
    • - {this.props.user.authenticated && ( -
    • - -
    • - )} - {this.props.user.authenticated && ( -
    • - -
    • - )} - {/*
    • - -
    • */} - {userIsOwner && ( -
    • - -
    • - )} -
    - )} + + + {this.props.t('SketchList.DropdownRename')} + + + {this.props.t('SketchList.DropdownDownload')} + + + {this.props.t('SketchList.DropdownDuplicate')} + + { + this.props.onAddToCollection(); + }} + > + {this.props.t('SketchList.DropdownAddToCollection')} + + + {/* + + Share + + */} + + {this.props.t('SketchList.DropdownDelete')} + + ); }; 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..63a4542d36 100644 --- a/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap +++ b/client/modules/IDE/components/__snapshots__/SketchList.unit.test.jsx.snap @@ -85,15 +85,19 @@ exports[` snapshot testing 1`] = ` - + +
    snapshot testing 1`] = ` - + +
    diff --git a/client/styles/components/_asset-list.scss b/client/styles/components/_asset-list.scss index 6f7c035993..7d8f6065e8 100644 --- a/client/styles/components/_asset-list.scss +++ b/client/styles/components/_asset-list.scss @@ -76,24 +76,3 @@ background-color: getThemifyVariable('background-color'); } } - -.asset-table__dropdown-button { - width:#{25 / $base-font-size}rem; - height:#{25 / $base-font-size}rem; - - @include themify() { - & polygon, & path { - fill: getThemifyVariable('inactive-text-color'); - } - } -} - -.asset-table__action-dialogue { - @extend %dropdown-open-right; - top: 63%; - right: calc(100% - 26px); -} - -.asset-table__action-option { - font-size: #{12 / $base-font-size}rem; -} diff --git a/client/styles/components/_sketch-list.scss b/client/styles/components/_sketch-list.scss index b03381b091..58e17f109b 100644 --- a/client/styles/components/_sketch-list.scss +++ b/client/styles/components/_sketch-list.scss @@ -100,17 +100,6 @@ font-weight: normal; } - -.sketch-list__dropdown-button { - width:#{25 / $base-font-size}rem; - height:#{25 / $base-font-size}rem; - @include themify() { - & polygon, & path { - fill: getThemifyVariable('inactive-text-color'); - } - } -} - .sketches-table__name { display: flex; align-items: center; @@ -120,12 +109,6 @@ width: #{35 / $base-font-size}rem; } -.sketch-list__action-dialogue { - @extend %dropdown-open-right; - top: 63%; - right: calc(100% - 26px); -} - .sketches-table__empty { text-align: center; font-size: #{16 / $base-font-size}rem; From 88a49610b3a97010f2c078abbf702458e7c68301 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Wed, 16 Aug 2023 12:17:05 -0500 Subject: [PATCH 10/10] incomplete - TableWithRename.jsx --- client/common/Table/StandardTable.jsx | 56 ++++ client/common/Table/TableBase.jsx | 2 +- client/common/Table/TableWithRename.jsx | 134 +++++++++ client/components/Dropdown/DropdownMenu.jsx | 10 +- client/modules/IDE/components/SketchList.jsx | 287 ++++++------------- 5 files changed, 284 insertions(+), 205 deletions(-) create mode 100644 client/common/Table/StandardTable.jsx create mode 100644 client/common/Table/TableWithRename.jsx diff --git a/client/common/Table/StandardTable.jsx b/client/common/Table/StandardTable.jsx new file mode 100644 index 0000000000..4789d0aa61 --- /dev/null +++ b/client/common/Table/StandardTable.jsx @@ -0,0 +1,56 @@ +import { omit } from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import TableBase from './TableBase'; + +/** + * Extends TableBase, but renders each row based on the columns. + * Can provide a `Dropdown` column which gets the `row` as a prop. + */ +function StandardTable({ Dropdown, columns, ...props }) { + const renderRow = (item) => ( + + {columns.map((column, i) => { + const value = item[column.field]; + const formatted = column.formatValue + ? column.formatValue(value) + : value; + if (i === 0) { + return ( + + {formatted} + + ); + } + return {formatted}; + })} + { + // TODO: styled-component + Dropdown && ( + + + + ) + } + + ); + return ( + + ); +} + +StandardTable.propTypes = { + ...omit(TableBase.propTypes, ['renderRow', 'addDropdownColumn']), + Dropdown: PropTypes.elementType +}; + +StandardTable.defaultProps = { + Dropdown: null +}; + +export default StandardTable; diff --git a/client/common/Table/TableBase.jsx b/client/common/Table/TableBase.jsx index fbdc5998a4..4fb7676d6b 100644 --- a/client/common/Table/TableBase.jsx +++ b/client/common/Table/TableBase.jsx @@ -61,7 +61,7 @@ function TableBase({ {addDropdownColumn && } - {sortedItems.map((item) => renderRow(item))} + {sortedItems.map((item) => renderRow(item, columns))} ); } diff --git a/client/common/Table/TableWithRename.jsx b/client/common/Table/TableWithRename.jsx new file mode 100644 index 0000000000..0447a943a6 --- /dev/null +++ b/client/common/Table/TableWithRename.jsx @@ -0,0 +1,134 @@ +import { omit } from 'lodash'; +import PropTypes from 'prop-types'; +import React, { useEffect, useRef, useState } from 'react'; +import TableBase from './TableBase'; + +const RenameInput = ({ text, onSubmit, onCancel }) => { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef]); + + const [renameValue, setRenameValue] = useState(text); + + const handleNameChange = () => { + const newName = renameValue.trim(); + if (newName.length === 0 || newName === text) { + onCancel(); + } else { + onSubmit(newName); + } + }; + + const handleKey = (e) => { + if (e.key === 'Enter') { + handleNameChange(); + } else if (e.key === 'Esc' || e.key === 'Escape') { + onCancel(); + } + }; + + return ( + setRenameValue(e.target.value)} + onKeyDown={handleKey} + onBlur={handleNameChange} + // onClick={(e) => e.stopPropagation()} + ref={inputRef} + /> + ); +}; + +RenameInput.propTypes = { + text: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +/** + * Extends TableBase, but renders each row based on the columns. + * Can provide a `Dropdown` column which gets the `row` as a prop. + */ +function TableWithRename({ + Dropdown, + dropdownProps, + columns, + handleRename, + ...props +}) { + const [editingRowId, setEditingRowId] = useState(null); + + console.log({ editingRowId }); + + const renderRow = (item) => ( + + {columns.map((column, i) => { + const value = item[column.field]; + const formatted = column.formatValue + ? column.formatValue(value, item) + : value; + const content = + column.field === 'name' && editingRowId === item.id ? ( + { + handleRename(newName, item.id); + setEditingRowId(null); + }} + onCancel={() => { + setEditingRowId(null); + }} + /> + ) : ( + formatted + ); + if (i === 0) { + return ( + + {content} + + ); + } + return {content}; + })} + { + // TODO: styled-component + Dropdown && ( + + { + setTimeout(() => setEditingRowId(item.id), 0); + }} + /> + + ) + } + + ); + return ( + + ); +} + +TableWithRename.propTypes = { + ...omit(TableBase.propTypes, ['renderRow', 'addDropdownColumn']), + Dropdown: PropTypes.elementType.isRequired, + handleRename: PropTypes.func.isRequired, + // eslint-disable-next-line react/forbid-prop-types + dropdownProps: PropTypes.object +}; + +TableWithRename.defaultProps = { + dropdownProps: {} +}; + +export default TableWithRename; diff --git a/client/components/Dropdown/DropdownMenu.jsx b/client/components/Dropdown/DropdownMenu.jsx index ce7e03837e..bf363ebf63 100644 --- a/client/components/Dropdown/DropdownMenu.jsx +++ b/client/components/Dropdown/DropdownMenu.jsx @@ -35,7 +35,11 @@ const DropdownMenu = forwardRef( }; return ( -
    +