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 (
-
+
-
+
{isOwner && (
setIsAddingSketches(true)}>
{t('Collection.AddSketch')}
@@ -110,7 +103,7 @@ function CollectionMetadata({ collectionId }) {
isFixedHeight
>
@@ -120,7 +113,17 @@ function CollectionMetadata({ collectionId }) {
}
CollectionMetadata.propTypes = {
- collectionId: PropTypes.string.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({}))
+ }).isRequired,
+ isOwner: PropTypes.bool.isRequired
};
export default CollectionMetadata;
From cc6d7629187b93227ea8bea22bc6b1d33ff5b776 Mon Sep 17 00:00:00 2001
From: Linda Paiste
Date: Sun, 13 Aug 2023 15:49:46 -0500
Subject: [PATCH 08/10] common Table component
---
client/common/Table/TableBase.jsx | 104 +++++
client/common/Table/TableBase.test.jsx | 63 +++
client/common/Table/TableElements.jsx | 10 +
client/common/Table/TableHeaderCell.jsx | 100 +++++
client/common/Table/TableHeaderCell.test.jsx | 89 ++++
client/constants.js | 3 -
client/modules/IDE/actions/collections.js | 7 +-
client/modules/IDE/actions/projects.js | 3 +-
client/modules/IDE/actions/sorting.js | 21 -
.../IDE/components/AddToCollectionList.jsx | 5 +-
.../components/AddToCollectionSketchList.jsx | 14 +-
client/modules/IDE/components/AssetList.jsx | 178 +++-----
.../CollectionList/CollectionList.jsx | 406 +++++-------------
.../CollectionList/CollectionListRow.jsx | 7 +-
client/modules/IDE/components/SketchList.jsx | 368 +++++-----------
.../IDE/components/SketchList.unit.test.jsx | 21 +-
.../SketchList.unit.test.jsx.snap | 20 +-
client/modules/IDE/reducers/sorting.js | 33 --
client/modules/IDE/selectors/collections.js | 34 +-
client/modules/IDE/selectors/projects.js | 29 +-
client/modules/IDE/selectors/users.js | 2 +
client/modules/User/components/Collection.jsx | 194 +++------
client/reducers.js | 2 -
client/styles/components/_asset-list.scss | 38 --
client/styles/components/_collection.scss | 5 -
client/styles/components/_sketch-list.scss | 50 ---
client/theme.js | 3 +
27 files changed, 754 insertions(+), 1055 deletions(-)
create mode 100644 client/common/Table/TableBase.jsx
create mode 100644 client/common/Table/TableBase.test.jsx
create mode 100644 client/common/Table/TableElements.jsx
create mode 100644 client/common/Table/TableHeaderCell.jsx
create mode 100644 client/common/Table/TableHeaderCell.test.jsx
delete mode 100644 client/modules/IDE/reducers/sorting.js
diff --git a/client/common/Table/TableBase.jsx b/client/common/Table/TableBase.jsx
new file mode 100644
index 0000000000..fbdc5998a4
--- /dev/null
+++ b/client/common/Table/TableBase.jsx
@@ -0,0 +1,104 @@
+import classNames from 'classnames';
+import { orderBy } from 'lodash';
+import PropTypes from 'prop-types';
+import React, { useState, useMemo } from 'react';
+import Loader from '../../modules/App/components/loader';
+import { DIRECTION } from '../../modules/IDE/actions/sorting';
+import { TableEmpty } from './TableElements';
+import TableHeaderCell, { StyledHeaderCell } from './TableHeaderCell';
+
+const toAscDesc = (direction) => (direction === DIRECTION.ASC ? 'asc' : 'desc');
+
+/**
+ * Renders the headers, loading spinner, empty message.
+ * Applies sorting to the items.
+ * Expects a `renderRow` prop to render each row.
+ */
+function TableBase({
+ initialSort,
+ columns,
+ items = [],
+ isLoading,
+ emptyMessage,
+ caption,
+ addDropdownColumn,
+ renderRow,
+ className
+}) {
+ const [sorting, setSorting] = useState(initialSort);
+
+ const sortedItems = useMemo(
+ () => orderBy(items, sorting.field, toAscDesc(sorting.direction)),
+ [sorting.field, sorting.direction, items]
+ );
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (items.length === 0) {
+ return {emptyMessage};
+ }
+
+ return (
+ .
+ summary={caption}
+ >
+
+
+ {columns.map((column) => (
+
+ ))}
+ {addDropdownColumn && }
+
+
+ {sortedItems.map((item) => renderRow(item))}
+
+ );
+}
+
+TableBase.propTypes = {
+ initialSort: PropTypes.shape({
+ field: PropTypes.string.isRequired,
+ direction: PropTypes.string.isRequired
+ }).isRequired,
+ columns: PropTypes.arrayOf(
+ PropTypes.shape({
+ field: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ defaultOrder: PropTypes.oneOf([DIRECTION.ASC, DIRECTION.DESC]),
+ formatValue: PropTypes.func
+ })
+ ).isRequired,
+ items: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired
+ // Will have other properties, depending on the type.
+ })
+ ),
+ renderRow: PropTypes.func.isRequired,
+ addDropdownColumn: PropTypes.bool,
+ isLoading: PropTypes.bool,
+ emptyMessage: PropTypes.string.isRequired,
+ caption: PropTypes.string,
+ className: PropTypes.string
+};
+
+TableBase.defaultProps = {
+ items: [],
+ isLoading: false,
+ caption: '',
+ addDropdownColumn: false,
+ className: ''
+};
+
+export default TableBase;
diff --git a/client/common/Table/TableBase.test.jsx b/client/common/Table/TableBase.test.jsx
new file mode 100644
index 0000000000..d49cd395f9
--- /dev/null
+++ b/client/common/Table/TableBase.test.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { DIRECTION } from '../../modules/IDE/actions/sorting';
+import { render, screen } from '../../test-utils';
+import TableBase from './TableBase';
+
+describe('', () => {
+ const items = [
+ { id: '1', name: 'abc', count: 3 },
+ { id: '2', name: 'def', count: 10 }
+ ];
+
+ const props = {
+ items,
+ initialSort: { field: 'count', direction: DIRECTION.DESC },
+ emptyMessage: 'No items found',
+ renderRow: (item) =>
,
+ columns: []
+ };
+
+ const subject = (overrideProps) =>
+ render();
+
+ jest.spyOn(props, 'renderRow');
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('shows a spinner when loading', () => {
+ subject({ isLoading: true });
+
+ expect(document.querySelector('.loader')).toBeInTheDocument();
+ });
+
+ it('show the `emptyMessage` when there are no items', () => {
+ subject({ items: [] });
+
+ expect(screen.getByText(props.emptyMessage)).toBeVisible();
+ });
+
+ it('calls `renderRow` function for each row', () => {
+ subject();
+
+ expect(props.renderRow).toHaveBeenCalledTimes(2);
+ });
+
+ it('sorts the items', () => {
+ subject();
+
+ expect(props.renderRow).toHaveBeenNthCalledWith(1, items[1]);
+ expect(props.renderRow).toHaveBeenNthCalledWith(2, items[0]);
+ });
+
+ it('does not add an extra header if `addDropdownColumn` is false', () => {
+ subject({ addDropdownColumn: false });
+ expect(screen.queryByRole('columnheader')).not.toBeInTheDocument();
+ });
+
+ it('adds an extra header if `addDropdownColumn` is true', () => {
+ subject({ addDropdownColumn: true });
+ expect(screen.getByRole('columnheader')).toBeInTheDocument();
+ });
+});
diff --git a/client/common/Table/TableElements.jsx b/client/common/Table/TableElements.jsx
new file mode 100644
index 0000000000..83656c7488
--- /dev/null
+++ b/client/common/Table/TableElements.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import styled from 'styled-components';
+import { remSize } from '../../theme';
+
+// eslint-disable-next-line import/prefer-default-export
+export const TableEmpty = styled.p`
+ text-align: center;
+ font-size: ${remSize(16)};
+ padding: ${remSize(42)} 0;
+`;
diff --git a/client/common/Table/TableHeaderCell.jsx b/client/common/Table/TableHeaderCell.jsx
new file mode 100644
index 0000000000..99cec86eac
--- /dev/null
+++ b/client/common/Table/TableHeaderCell.jsx
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import styled from 'styled-components';
+import { DIRECTION } from '../../modules/IDE/actions/sorting';
+import { prop, remSize } from '../../theme';
+import { SortArrowDownIcon, SortArrowUpIcon } from '../icons';
+
+const opposite = (direction) =>
+ direction === DIRECTION.ASC ? DIRECTION.DESC : DIRECTION.ASC;
+
+const ariaSort = (direction) =>
+ direction === DIRECTION.ASC ? 'ascending' : 'descending';
+
+const TableHeaderTitle = styled.span`
+ border-bottom: 2px dashed transparent;
+ padding: ${remSize(3)} 0;
+ color: ${prop('inactiveTextColor')};
+ ${(props) => props.selected && `border-color: ${prop('accentColor')(props)}`}
+`;
+
+export const StyledHeaderCell = styled.th`
+ height: ${remSize(32)};
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background-color: ${prop('backgroundColor')};
+ font-weight: normal;
+ &:nth-child(1) {
+ padding-left: ${remSize(12)};
+ }
+ button {
+ display: flex;
+ align-items: center;
+ height: ${remSize(35)};
+ svg {
+ margin-left: ${remSize(8)};
+ fill: ${prop('inactiveTextColor')};
+ }
+ }
+`;
+
+const TableHeaderCell = ({ sorting, field, title, defaultOrder, onSort }) => {
+ const isSelected = sorting.field === field;
+ const { direction } = sorting;
+ const { t } = useTranslation();
+ const directionWhenClicked = isSelected ? opposite(direction) : defaultOrder;
+ // TODO: more generic translation properties
+ const translationKey =
+ directionWhenClicked === DIRECTION.ASC
+ ? 'SketchList.ButtonLabelAscendingARIA'
+ : 'SketchList.ButtonLabelDescendingARIA';
+ const buttonLabel = t(translationKey, {
+ displayName: title
+ });
+
+ return (
+
+ onSort({ field, direction: directionWhenClicked })}
+ aria-label={buttonLabel}
+ aria-pressed={isSelected}
+ >
+ {title}
+ {/* TODO: show icons on hover of cell */}
+ {isSelected && direction === DIRECTION.ASC && (
+
+ )}
+ {isSelected && direction === DIRECTION.DESC && (
+
+ )}
+
+
+ );
+};
+
+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() && (
-
-
-
- {t('AssetList.HeaderName')} |
- {t('AssetList.HeaderSize')} |
- {t('AssetList.HeaderSketch')} |
- |
-
-
-
- {assetList.map((asset) => (
-
- ))}
-
-
+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 (
-
- this.props.toggleDirectionForField(fieldName)}
- aria-label={buttonLabel}
- >
- {displayName}
- {field === fieldName &&
- direction === SortingActions.DIRECTION.ASC && (
-
- )}
- {field === fieldName &&
- direction === SortingActions.DIRECTION.DESC && (
-
- )}
-
- |
- );
- };
-
- 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
})}
- />
-
+
+
+
+ (
+ 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 (
-
- this.props.toggleDirectionForField(fieldName)}
- aria-label={buttonLabel}
- >
- {displayName}
- {field === fieldName &&
- direction === SortingActions.DIRECTION.ASC && (
-
- )}
- {field === fieldName &&
- direction === SortingActions.DIRECTION.DESC && (
-
- )}
-
- |
- );
- };
-
- 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 (
-
- this.props.toggleDirectionForField(fieldName)}
- aria-label={buttonLabel}
- >
- {displayName}
- {field === fieldName &&
- direction === SortingActions.DIRECTION.ASC && (
-
- )}
- {field === fieldName &&
- direction === SortingActions.DIRECTION.DESC && (
-
- )}
-
- |
- );
- }
-
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 (
+
+
+
+
+ );
+};
- 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.Delete')}
-
-
- -
-
- {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 && (
-
- -
-
- {this.props.t('CollectionListRow.AddSketch')}
-
-
- {userIsOwner && (
- -
-
- {this.props.t('CollectionListRow.Delete')}
-
-
- )}
- {userIsOwner && (
- -
-
- {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.t('SketchList.DropdownRename')}
-
-
- )}
- -
-
- {this.props.t('SketchList.DropdownDownload')}
-
-
- {this.props.user.authenticated && (
- -
-
- {this.props.t('SketchList.DropdownDuplicate')}
-
-
- )}
- {this.props.user.authenticated && (
- -
- {
- this.props.onAddToCollection();
- this.closeAll();
- }}
- onBlur={this.onBlurComponent}
- onFocus={this.onFocusComponent}
- >
- {this.props.t('SketchList.DropdownAddToCollection')}
-
-
- )}
- {/* -
-
- Share
-
-
*/}
- {userIsOwner && (
- -
-
- {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 (
-
+
{
+ setTimeout(close, 100);
+ }}
onBlur={handleBlur}
onFocus={handleFocus}
>
diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx
index a341ddb34d..2512553047 100644
--- a/client/modules/IDE/components/SketchList.jsx
+++ b/client/modules/IDE/components/SketchList.jsx
@@ -2,22 +2,24 @@ import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useTranslation } from 'react-i18next';
-import { connect, useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
-import { bindActionCreators } from 'redux';
import slugify from 'slugify';
-import TableBase from '../../../common/Table/TableBase';
+import TableWithRename from '../../../common/Table/TableWithRename';
import MenuItem from '../../../components/Dropdown/MenuItem';
import TableDropdown from '../../../components/Dropdown/TableDropdown';
import dates from '../../../utils/formatDate';
import Overlay from '../../App/components/Overlay';
-import * as IdeActions from '../actions/ide';
-import * as ProjectActions from '../actions/project';
+import {
+ changeProjectName,
+ cloneProject,
+ deleteProject
+} from '../actions/project';
import { getProjects } from '../actions/projects';
import { DIRECTION } from '../actions/sorting';
import getSortedSketches from '../selectors/projects';
-import { selectCurrentUsername } from '../selectors/users';
+import { getAuthenticated, selectCurrentUsername } from '../selectors/users';
import AddToCollectionList from './AddToCollectionList';
import getConfig from '../../../utils/getConfig';
@@ -26,62 +28,31 @@ const ROOT_URL = getConfig('API_URL');
const formatDateCell = (date, mobile = false) =>
dates.format(date, { showTime: !mobile });
-class SketchListRowBase extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- renameOpen: false,
- renameValue: props.sketch.name
- };
- this.renameInput = React.createRef();
+// TODO: move to a util file and use this in share modals.
+const sketchUrl = (sketch, username) => {
+ if (username === 'p5') {
+ return `/${username}/sketches/${slugify(sketch.name, '_')}`;
}
+ return `/${username}/sketches/${sketch.id}`;
+};
- openRename = () => {
- this.setState(
- {
- renameOpen: true,
- renameValue: this.props.sketch.name
- },
- () => this.renameInput.current.focus()
- );
- };
+const SketchDropdown = ({
+ row: sketch,
+ onClickRename,
+ ownerUsername,
+ setSketchToAddToCollection
+}) => {
+ const { t } = useTranslation();
- closeRename = () => {
- this.setState({
- renameOpen: false
- });
- };
+ const dispatch = useDispatch();
- handleRenameChange = (e) => {
- this.setState({
- renameValue: e.target.value
- });
- };
+ const isAuthenticated = useSelector(getAuthenticated);
- handleRenameEnter = (e) => {
- if (e.key === 'Enter') {
- this.updateName();
- this.closeRename();
- }
- };
-
- handleRenameBlur = () => {
- this.updateName();
- this.closeRename();
- };
+ const currentUser = useSelector((state) => state.user.username);
- updateName = () => {
- const isValid = this.state.renameValue.trim().length !== 0;
- if (isValid) {
- this.props.changeProjectName(
- this.props.sketch.id,
- this.state.renameValue.trim()
- );
- }
- };
+ const userIsOwner = ownerUsername === currentUser;
- handleSketchDownload = () => {
- const { sketch } = this.props;
+ const handleSketchDownload = () => {
const downloadLink = document.createElement('a');
downloadLink.href = `${ROOT_URL}/projects/${sketch.id}/zip`;
downloadLink.download = `${sketch.name}.zip`;
@@ -90,160 +61,66 @@ class SketchListRowBase extends React.Component {
document.body.removeChild(downloadLink);
};
- handleSketchDuplicate = () => {
- this.props.cloneProject(this.props.sketch);
+ const handleSketchDuplicate = () => {
+ dispatch(cloneProject(sketch));
};
- handleSketchShare = () => {
- this.props.showShareModal(
- this.props.sketch.id,
- this.props.sketch.name,
- this.props.username
- );
- };
-
- handleSketchDelete = () => {
+ const handleSketchDelete = () => {
if (
window.confirm(
- this.props.t('Common.DeleteConfirmation', {
- name: this.props.sketch.name
+ t('Common.DeleteConfirmation', {
+ name: sketch.name
})
)
) {
- this.props.deleteProject(this.props.sketch.id);
+ dispatch(deleteProject(sketch.id));
}
};
- renderDropdown = () => {
- const userIsOwner = this.props.user.username === this.props.username;
-
- return (
-
-
-
-
-
-
+ return (
+
+
+
+
+
- {/*
+ {/*
*/}
-
-
- |
- );
- };
-
- render() {
- const { sketch, username, mobile } = this.props;
- const { renameOpen, renameValue } = this.state;
- let url = `/${username}/sketches/${sketch.id}`;
- if (username === 'p5') {
- url = `/${username}/sketches/${slugify(sketch.name, '_')}`;
- }
-
- const name = (
-
- {renameOpen ? '' : sketch.name}
- {renameOpen && (
- e.stopPropagation()}
- ref={this.renameInput}
- />
- )}
-
- );
-
- return (
-
-
- {name} |
-
- {mobile && 'Created: '}
- {formatDateCell(sketch.createdAt, mobile)}
- |
-
- {mobile && 'Updated: '}
- {formatDateCell(sketch.updatedAt, mobile)}
- |
- {this.renderDropdown()}
-
-
- );
- }
-}
+
+
+ );
+};
-SketchListRowBase.propTypes = {
- sketch: PropTypes.shape({
+SketchDropdown.propTypes = {
+ row: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
updatedAt: PropTypes.string.isRequired
}).isRequired,
- username: PropTypes.string.isRequired,
- user: PropTypes.shape({
- username: PropTypes.string,
- authenticated: PropTypes.bool.isRequired
- }).isRequired,
- deleteProject: PropTypes.func.isRequired,
- showShareModal: PropTypes.func.isRequired,
- cloneProject: PropTypes.func.isRequired,
- changeProjectName: PropTypes.func.isRequired,
- onAddToCollection: PropTypes.func.isRequired,
- mobile: PropTypes.bool,
- t: PropTypes.func.isRequired
-};
-
-SketchListRowBase.defaultProps = {
- mobile: false
+ ownerUsername: PropTypes.string.isRequired,
+ onClickRename: PropTypes.func.isRequired,
+ setSketchToAddToCollection: PropTypes.func.isRequired
};
-function mapStateToPropsSketchListRow(state) {
- return {
- user: state.user
- };
-}
-
-function mapDispatchToPropsSketchListRow(dispatch) {
- return bindActionCreators(
- Object.assign({}, ProjectActions, IdeActions),
- dispatch
- );
-}
-
-const SketchListRow = connect(
- mapStateToPropsSketchListRow,
- mapDispatchToPropsSketchListRow
-)(SketchListRowBase);
-
const SketchList = ({ username, mobile }) => {
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -263,6 +140,13 @@ const SketchList = ({ username, mobile }) => {
const [sketchToAddToCollection, setSketchToAddToCollection] = useState(null);
+ const handleRename = (newName, sketchId) => {
+ const isValid = newName.trim().length !== 0;
+ if (isValid) {
+ dispatch(changeProjectName(sketchId, newName.trim()));
+ }
+ };
+
return (
@@ -274,14 +158,17 @@ const SketchList = ({ username, mobile }) => {
})}
- (
+ {name}
+ )
},
{
field: 'createdAt',
@@ -289,7 +176,8 @@ const SketchList = ({ username, mobile }) => {
title: t('SketchList.HeaderCreatedAt', {
context: mobile ? 'mobile' : ''
}),
- formatValue: (value) => formatDateCell(value, mobile)
+ formatValue: (value) =>
+ (mobile ? 'Created: ' : '') + formatDateCell(value, mobile)
},
{
field: 'updatedAt',
@@ -297,7 +185,8 @@ const SketchList = ({ username, mobile }) => {
title: t('SketchList.HeaderUpdatedAt', {
context: mobile ? 'mobile' : ''
}),
- formatValue: (value) => formatDateCell(value, mobile)
+ formatValue: (value) =>
+ (mobile ? 'Updated: ' : '') + formatDateCell(value, mobile)
}
]}
addDropdownColumn
@@ -308,18 +197,12 @@ const SketchList = ({ username, mobile }) => {
emptyMessage={t('SketchList.NoSketches')}
caption={t('SketchList.TableSummary')}
// TODO: figure out how to use the StandardTable -- needs dropdown and styling
- renderRow={(sketch) => (
- {
- setSketchToAddToCollection(sketch);
- }}
- t={t}
- />
- )}
+ handleRename={handleRename}
+ Dropdown={SketchDropdown}
+ dropdownProps={{
+ setSketchToAddToCollection,
+ ownerUsername: username
+ }}
/>
{sketchToAddToCollection && (