diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index 720f21734b..cf827b0354 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -1,187 +1,63 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { connect, useDispatch } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { Link } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Helmet } from 'react-helmet'; -import prettyBytes from 'pretty-bytes'; -import { useTranslation, withTranslation } from 'react-i18next'; -import MenuItem from '../../../components/Dropdown/MenuItem'; -import TableDropdown from '../../../components/Dropdown/TableDropdown'; - +import { useTranslation } from 'react-i18next'; +import AssetListRow from './AssetListRow'; import Loader from '../../App/components/loader'; -import { deleteAssetRequest } from '../actions/assets'; import * as AssetActions from '../actions/assets'; -const AssetMenu = ({ item: asset }) => { +const AssetList = () => { const { t } = useTranslation(); - const dispatch = useDispatch(); + const { username, assetList, loading } = useSelector((state) => ({ + username: state.user.username, + assetList: state.assets.list, + loading: state.loading + })); - const handleAssetDelete = () => { - const { key, name } = asset; - if (window.confirm(t('Common.DeleteConfirmation', { name }))) { - dispatch(deleteAssetRequest(key)); - } - }; - - return ( - - {t('AssetList.Delete')} - - {t('AssetList.OpenNewTab')} - - - ); -}; - -AssetMenu.propTypes = { - item: PropTypes.shape({ - key: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - name: PropTypes.string.isRequired - }).isRequired -}; - -const AssetListRowBase = ({ asset, username }) => ( - - - - {asset.name} - - - {prettyBytes(asset.size)} - - {asset.sketchId && ( - - {asset.sketchName} - - )} - - - - - -); - -AssetListRowBase.propTypes = { - asset: PropTypes.shape({ - key: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - sketchId: PropTypes.string, - sketchName: PropTypes.string, - name: PropTypes.string.isRequired, - size: PropTypes.number.isRequired - }).isRequired, - username: PropTypes.string.isRequired -}; - -function mapStateToPropsAssetListRow(state) { - return { - username: state.user.username - }; -} - -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'); - } + useEffect(() => { + dispatch(AssetActions.getAssets()); + }, []); - hasAssets() { - return !this.props.loading && this.props.assetList.length > 0; - } + const hasAssets = () => !loading && assetList.length > 0; - renderLoader() { - if (this.props.loading) return ; - return null; - } + const renderLoader = () => (loading ? : null); - renderEmptyTable() { - if (!this.props.loading && this.props.assetList.length === 0) { + const renderEmptyTable = () => { + if (!loading && assetList.length === 0) { return ( -

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

+

{t('AssetList.NoUploadedAssets')}

); } return null; - } - - render() { - const { assetList, t } = this.props; - return ( -
- - {this.getAssetsTitle()} - - {this.renderLoader()} - {this.renderEmptyTable()} - {this.hasAssets() && ( - - - - - - - - - - - {assetList.map((asset) => ( - - ))} - -
{t('AssetList.HeaderName')}{t('AssetList.HeaderSize')}{t('AssetList.HeaderSketch')}
- )} -
- ); - } -} - -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); -} + return ( +
+ + {t('AssetList.Title')} + + {renderLoader()} + {renderEmptyTable()} + {hasAssets() && ( + + + + + + + + + + + {assetList.map((asset) => ( + + ))} + +
{t('AssetList.HeaderName')}{t('AssetList.HeaderSize')}{t('AssetList.HeaderSketch')}
+ )} +
+ ); +}; -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(AssetList) -); +export default AssetList; diff --git a/client/modules/IDE/components/AssetListRow.jsx b/client/modules/IDE/components/AssetListRow.jsx new file mode 100644 index 0000000000..7e7af8f013 --- /dev/null +++ b/client/modules/IDE/components/AssetListRow.jsx @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import prettyBytes from 'pretty-bytes'; +import MenuItem from '../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../components/Dropdown/TableDropdown'; +import { deleteAssetRequest } from '../actions/assets'; + +const AssetMenu = ({ item: asset }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const handleAssetDelete = () => { + const { key, name } = asset; + if (window.confirm(t('Common.DeleteConfirmation', { name }))) { + dispatch(deleteAssetRequest(key)); + } + }; + + return ( + + {t('AssetList.Delete')} + + {t('AssetList.OpenNewTab')} + + + ); +}; + +AssetMenu.propTypes = { + item: PropTypes.shape({ + key: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired +}; + +const AssetListRow = ({ asset, username }) => ( + + + + {asset.name} + + + {prettyBytes(asset.size)} + + {asset.sketchId && ( + + {asset.sketchName} + + )} + + + + + +); + +AssetListRow.propTypes = { + asset: PropTypes.shape({ + key: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + sketchId: PropTypes.string, + sketchName: PropTypes.string, + name: PropTypes.string.isRequired, + size: PropTypes.number.isRequired + }).isRequired, + username: PropTypes.string.isRequired +}; + +export default AssetListRow; diff --git a/client/modules/IDE/components/CollectionList/CollectionList.jsx b/client/modules/IDE/components/CollectionList/CollectionList.jsx index 9d641f5e66..cf25be5018 100644 --- a/client/modules/IDE/components/CollectionList/CollectionList.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionList.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { Helmet } from 'react-helmet'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import classNames from 'classnames'; @@ -22,113 +22,109 @@ 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); +const CollectionList = ({ + user, + projectId, + getCollections, + getProject, + collections, + username: propsUsername, + loading, + toggleDirectionForField, + resetSorting, + sorting, + project, + mobile +}) => { + const { t } = useTranslation(); + const [hasLoadedData, setHasLoadedData] = useState(false); + const [ + addingSketchesToCollectionId, + setAddingSketchesToCollectionId + ] = useState(null); - if (props.projectId) { - props.getProject(props.projectId); + useEffect(() => { + if (projectId) { + getProject(projectId); } + getCollections(propsUsername || user.username); + resetSorting(); + }, []); - 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 - }); + useEffect(() => { + if (!loading) { + setHasLoadedData(true); } - } + }, [loading]); - getTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('CollectionList.Title'); + const getTitle = useMemo(() => { + if (propsUsername === user.username) { + return t('CollectionList.Title'); } - return this.props.t('CollectionList.AnothersTitle', { - anotheruser: this.props.username + return t('CollectionList.AnothersTitle', { + anotheruser: propsUsername }); - } + }, [propsUsername, user.username, t]); - showAddSketches = (collectionId) => { - this.setState({ - addingSketchesToCollectionId: collectionId - }); + const showAddSketches = (collectionId) => { + setAddingSketchesToCollectionId(collectionId); }; - hideAddSketches = () => { - this.setState({ - addingSketchesToCollectionId: null - }); + const hideAddSketches = () => { + setAddingSketchesToCollectionId(null); }; - hasCollections() { - return ( - (!this.props.loading || this.state.hasLoadedData) && - this.props.collections.length > 0 - ); - } + const hasCollections = () => + (!loading || hasLoadedData) && collections.length > 0; - _renderLoader() { - if (this.props.loading && !this.state.hasLoadedData) return ; + const renderLoader = () => { + if (loading && !hasLoadedData) return ; return null; - } + }; - _renderEmptyTable() { - if (!this.props.loading && this.props.collections.length === 0) { + const renderEmptyTable = () => { + if (!loading && collections.length === 0) { return (

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

); } return null; - } + }; - _getButtonLabel = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; + const getButtonLabel = (fieldName, displayName) => { + const { field, direction } = sorting; let buttonLabel; if (field !== fieldName) { - if (field === 'name') { - buttonLabel = this.props.t('CollectionList.ButtonLabelAscendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('CollectionList.ButtonLabelDescendingARIA', { - displayName - }); - } + buttonLabel = + field === 'name' + ? t('CollectionList.ButtonLabelAscendingARIA', { displayName }) + : t('CollectionList.ButtonLabelDescendingARIA', { displayName }); } else if (direction === SortingActions.DIRECTION.ASC) { - buttonLabel = this.props.t('CollectionList.ButtonLabelDescendingARIA', { + buttonLabel = t('CollectionList.ButtonLabelDescendingARIA', { displayName }); } else { - buttonLabel = this.props.t('CollectionList.ButtonLabelAscendingARIA', { + buttonLabel = t('CollectionList.ButtonLabelAscendingARIA', { displayName }); } return buttonLabel; }; - _renderFieldHeader = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; + const renderFieldHeader = (fieldName, displayName) => { + const { field, direction } = sorting; const headerClass = classNames({ 'sketches-table__header': true, 'sketches-table__header--selected': field === fieldName }); - const buttonLabel = this._getButtonLabel(fieldName, displayName); + const buttonLabel = getButtonLabel(fieldName, displayName); return ( + + + )} + + + +
+
    + {isFolder && ( + <> +
  • + +
  • +
  • + +
  • + {authenticated && ( +
  • + +
  • + )} + + )} +
  • +
  • +
  • -
- )} - - { - this.fileNameInput = element; - }} - onBlur={this.handleFileNameBlur} - onKeyPress={this.handleKeyPress} - /> - -
-
    - {isFolder && ( - -
  • - -
  • -
  • - -
  • - {this.props.authenticated && ( -
  • - -
  • - )} -
    - )} -
  • - -
  • -
  • - -
  • -
-
+ + - )} - {this.props.children && ( -
    - {this.props.children.map(this.renderChild)} -
- )} - - ); - } -} + + )} + {children && ( +
    + {children.map((childId) => ( +
  • + +
  • + ))} +
+ )} + + ); +}; FileNode.propTypes = { id: PropTypes.string.isRequired, @@ -438,7 +381,6 @@ FileNode.propTypes = { canEdit: PropTypes.bool.isRequired, openUploadFileModal: PropTypes.func.isRequired, authenticated: PropTypes.bool.isRequired, - t: PropTypes.func.isRequired, onClickFile: PropTypes.func }; @@ -464,11 +406,9 @@ function mapDispatchToProps(dispatch) { return bindActionCreators(Object.assign(FileActions, IDEActions), dispatch); } -const TranslatedFileNode = withTranslation()(FileNode); - const ConnectedFileNode = connect( mapStateToProps, mapDispatchToProps -)(TranslatedFileNode); +)(FileNode); -export { TranslatedFileNode as FileNode, ConnectedFileNode as default }; +export { FileNode, ConnectedFileNode as default }; diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index 6092cd686f..3846fe278b 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -1,433 +1,180 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import classNames from 'classnames'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Helmet } from 'react-helmet'; -import { withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; 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'; import * as CollectionsActions from '../actions/collections'; import * as ToastActions from '../actions/toast'; import * as SortingActions from '../actions/sorting'; -import * as IdeActions from '../actions/ide'; import getSortedSketches from '../selectors/projects'; import Loader from '../../App/components/loader'; import Overlay from '../../App/components/Overlay'; import AddToCollectionList from './AddToCollectionList'; -import getConfig from '../../../utils/getConfig'; - +import SketchListRowBase from './SketchListRowBase'; import ArrowUpIcon from '../../../images/sort-arrow-up.svg'; import ArrowDownIcon from '../../../images/sort-arrow-down.svg'; -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(); - } - - openRename = () => { - this.setState( - { - renameOpen: true, - renameValue: this.props.sketch.name - }, - () => this.renameInput.current.focus() - ); - }; - - closeRename = () => { - this.setState({ - renameOpen: false - }); - }; - - handleRenameChange = (e) => { - this.setState({ - renameValue: e.target.value - }); - }; - - handleRenameEnter = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - this.updateName(); - this.closeRename(); - } - }; - - handleRenameBlur = () => { - this.updateName(); - this.closeRename(); - }; - - updateName = () => { - const isValid = this.state.renameValue.trim().length !== 0; - if (isValid) { - this.props.changeProjectName( - this.props.sketch.id, - this.state.renameValue.trim() - ); - } - }; - - handleSketchDownload = () => { - const { sketch } = this.props; - const downloadLink = document.createElement('a'); - downloadLink.href = `${ROOT_URL}/projects/${sketch.id}/zip`; - downloadLink.download = `${sketch.name}.zip`; - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); - }; - - handleSketchDuplicate = () => { - this.props.cloneProject(this.props.sketch); - }; - - handleSketchShare = () => { - this.props.showShareModal( - this.props.sketch.id, - this.props.sketch.name, - this.props.username - ); - }; - - handleSketchDelete = () => { - if ( - window.confirm( - this.props.t('Common.DeleteConfirmation', { - name: this.props.sketch.name - }) - ) - ) { - this.props.deleteProject(this.props.sketch.id); - } - }; - - renderDropdown = () => { - const userIsOwner = this.props.user.username === this.props.username; - - return ( - - - - {this.props.t('SketchList.DropdownRename')} - - - {this.props.t('SketchList.DropdownDownload')} - - - {this.props.t('SketchList.DropdownDuplicate')} - - { - this.props.onAddToCollection(); - }} - > - {this.props.t('SketchList.DropdownAddToCollection')} - - - {/* - - Share - - */} - - {this.props.t('SketchList.DropdownDelete')} - - - - ); - }; - - 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 SketchList = ({ + user, + getProjects, + sketches, + username, + loading, + sorting, + toggleDirectionForField, + resetSorting, + mobile +}) => { + const [isInitialDataLoad, setIsInitialDataLoad] = useState(true); + const [sketchToAddToCollection, setSketchToAddToCollection] = useState(null); + const { t } = useTranslation(); + + useEffect(() => { + getProjects(username); + resetSorting(); + }, [getProjects, username, resetSorting]); + + useEffect(() => { + if (Array.isArray(sketches)) { + setIsInitialDataLoad(false); } - - const name = ( - - {renameOpen ? '' : sketch.name} - {renameOpen && ( - e.stopPropagation()} - ref={this.renameInput} - maxLength={128} - /> - )} - - ); - - return ( - - - {name} - {formatDateCell(sketch.createdAt, mobile)} - {formatDateCell(sketch.updatedAt, mobile)} - {this.renderDropdown()} - - - ); - } -} - -SketchListRowBase.propTypes = { - sketch: 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 -}; - -function mapDispatchToPropsSketchListRow(dispatch) { - return bindActionCreators( - Object.assign({}, ProjectActions, IdeActions), - dispatch + }, [sketches]); + + const getSketchesTitle = useMemo( + () => + username === user.username + ? t('SketchList.Title') + : t('SketchList.AnothersTitle', { anotheruser: username }), + [username, user.username, t] ); -} - -const SketchListRow = connect( - null, - mapDispatchToPropsSketchListRow -)(SketchListRowBase); -class SketchList extends React.Component { - constructor(props) { - super(props); - this.props.getProjects(this.props.username); - this.props.resetSorting(); + const isLoading = () => loading && isInitialDataLoad; - this.state = { - isInitialDataLoad: true - }; - } + const hasSketches = () => !isLoading() && sketches.length > 0; - 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; - } + const renderLoader = () => isLoading() && ; - _renderLoader() { - if (this.isLoading()) return ; - return null; - } - - _renderEmptyTable() { - if (!this.isLoading() && this.props.sketches.length === 0) { + const renderEmptyTable = () => { + if (!isLoading() && sketches.length === 0) { return ( -

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

+

{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 ( - - - - ); - }; + const getButtonLabel = useCallback( + (fieldName, displayName) => { + const { field, direction } = sorting; + if (field !== fieldName) { + return field === 'name' + ? t('SketchList.ButtonLabelAscendingARIA', { displayName }) + : t('SketchList.ButtonLabelDescendingARIA', { displayName }); + } + return direction === SortingActions.DIRECTION.ASC + ? t('SketchList.ButtonLabelDescendingARIA', { displayName }) + : t('SketchList.ButtonLabelAscendingARIA', { displayName }); + }, + [sorting, t] + ); - 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() && ( - { + const { field, direction } = sorting; + const headerClass = classNames({ + 'sketches-table__header': true, + 'sketches-table__header--selected': field === fieldName + }); + const buttonLabel = getButtonLabel(fieldName, displayName); + return ( + - - {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} + {displayName} + {field === fieldName && + (direction === SortingActions.DIRECTION.ASC ? ( + + ) : ( + ))} - -
+
- )} - {this.state.sketchToAddToCollection && ( - - this.setState({ sketchToAddToCollection: null }) - } - > - - - )} -
- ); - } -} + + + ); + }, + [sorting, getButtonLabel, toggleDirectionForField, t] + ); + + return ( +
+ + {getSketchesTitle} + + {renderLoader()} + {renderEmptyTable()} + {hasSketches() && ( + + + + {renderFieldHeader('name', t('SketchList.HeaderName'))} + {renderFieldHeader( + 'createdAt', + t('SketchList.HeaderCreatedAt', { + context: mobile ? 'mobile' : '' + }) + )} + {renderFieldHeader( + 'updatedAt', + t('SketchList.HeaderUpdatedAt', { + context: mobile ? 'mobile' : '' + }) + )} + + + + + {sketches.map((sketch) => ( + setSketchToAddToCollection(sketch)} + t={t} + /> + ))} + +
+ )} + {sketchToAddToCollection && ( + setSketchToAddToCollection(null)} + > + + + )} +
+ ); +}; SketchList.propTypes = { user: PropTypes.shape({ @@ -451,8 +198,7 @@ SketchList.propTypes = { field: PropTypes.string.isRequired, direction: PropTypes.string.isRequired }).isRequired, - mobile: PropTypes.bool, - t: PropTypes.func.isRequired + mobile: PropTypes.bool }; SketchList.defaultProps = { @@ -483,6 +229,4 @@ function mapDispatchToProps(dispatch) { ); } -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(SketchList) -); +export default connect(mapStateToProps, mapDispatchToProps)(SketchList); diff --git a/client/modules/IDE/components/SketchList.unit.test.jsx b/client/modules/IDE/components/SketchList.unit.test.jsx index 162d12bcc1..9110c43951 100644 --- a/client/modules/IDE/components/SketchList.unit.test.jsx +++ b/client/modules/IDE/components/SketchList.unit.test.jsx @@ -8,8 +8,6 @@ import SketchList from './SketchList'; import { reduxRender, fireEvent, screen, within } from '../../../test-utils'; import { initialTestState } from '../../../testData/testReduxStore'; -jest.mock('../../../i18n'); - const server = setupServer( rest.get(`/${initialTestState.user.username}/projects`, (req, res, ctx) => // it just needs to return something so it doesn't throw an error diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx new file mode 100644 index 0000000000..5c00015fdc --- /dev/null +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import slugify from 'slugify'; +import React, { useState, useRef, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import * as ProjectActions from '../actions/project'; +import * as IdeActions from '../actions/ide'; +import TableDropdown from '../../../components/Dropdown/TableDropdown'; +import MenuItem from '../../../components/Dropdown/MenuItem'; +import dates from '../../../utils/formatDate'; +import getConfig from '../../../utils/getConfig'; + +const ROOT_URL = getConfig('API_URL'); + +const formatDateCell = (date, mobile = false) => + dates.format(date, { showTime: !mobile }); + +const SketchListRowBase = ({ + sketch, + username, + user, + changeProjectName, + cloneProject, + deleteProject, + t, + mobile, + onAddToCollection +}) => { + const [renameOpen, setRenameOpen] = useState(false); + const [renameValue, setRenameValue] = useState(sketch.name); + const renameInput = useRef(null); + + const openRename = useCallback(() => { + setRenameOpen(true); + setRenameValue(sketch.name); + renameInput.current.focus(); + }, [sketch.name]); + + const closeRename = () => setRenameOpen(false); + + const updateName = useCallback(() => { + if (renameValue.trim().length > 0) { + changeProjectName(sketch.id, renameValue.trim()); + } + }, [renameValue, sketch.id, changeProjectName]); + + const handleRenameChange = (e) => setRenameValue(e.target.value); + + const handleRenameEnter = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + updateName(); + closeRename(); + } + }; + + const handleRenameBlur = () => { + updateName(); + closeRename(); + }; + + const handleSketchDownload = () => { + const downloadLink = document.createElement('a'); + downloadLink.href = `${ROOT_URL}/projects/${sketch.id}/zip`; + downloadLink.download = `${sketch.name}.zip`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); + }; + + const handleSketchDuplicate = () => cloneProject(sketch); + const handleSketchDelete = () => { + if (window.confirm(t('Common.DeleteConfirmation', { name: sketch.name }))) { + deleteProject(sketch.id); + } + }; + + const userIsOwner = user.username === username; + + let url = `/${username}/sketches/${sketch.id}`; + if (username === 'p5') { + url = `/${username}/sketches/${slugify(sketch.name, '_')}`; + } + + const name = ( + <> + {renameOpen ? '' : sketch.name} + {renameOpen && ( + e.stopPropagation()} + ref={renameInput} + maxLength={128} + /> + )} + + ); + + return ( + + {name} + {formatDateCell(sketch.createdAt, mobile)} + {formatDateCell(sketch.updatedAt, mobile)} + + + + {t('SketchList.DropdownRename')} + + + {t('SketchList.DropdownDownload')} + + + {t('SketchList.DropdownDuplicate')} + + + {t('SketchList.DropdownAddToCollection')} + + + {t('SketchList.DropdownDelete')} + + + + + ); +}; + +SketchListRowBase.propTypes = { + sketch: 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, + cloneProject: PropTypes.func.isRequired, + changeProjectName: PropTypes.func.isRequired, + onAddToCollection: PropTypes.func.isRequired, + mobile: PropTypes.bool, + t: PropTypes.func.isRequired +}; + +SketchListRowBase.defaultProps = { + mobile: false +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators( + Object.assign({}, ProjectActions, IdeActions), // Binding both ProjectActions and IdeActions + dispatch + ); +} + +export default connect( + null, + mapDispatchToPropsSketchListRow +)(SketchListRowBase); diff --git a/client/modules/User/components/Collection.jsx b/client/modules/User/components/Collection.jsx index 939b2b851f..59f89e4a34 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -1,366 +1,166 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; -import { bindActionCreators } from 'redux'; -import { useTranslation, withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; - -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 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'; import CollectionMetadata from './CollectionMetadata'; +import CollectionItemRow from './CollectionItemRow'; -const CollectionItemRowBase = ({ - collection, - item, - isOwner, - removeFromCollection -}) => { +const Collection = ({ 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(); + + const { user, collection, sorting, loading, username } = useSelector( + (state) => ({ + user: state.user, + collection: getCollection(state, collectionId), + sorting: state.sorting, + loading: state.loading, + username: state.user.username + }) ); -}; - -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 - ); -} + useEffect(() => { + dispatch(CollectionsActions.getCollections(username)); + dispatch(SortingActions.resetSorting()); + }, [dispatch, username]); -const CollectionItemRow = connect( - null, - mapDispatchToPropsSketchListRow -)(CollectionItemRowBase); + const isOwner = () => + user != null && + user.username && + collection?.owner?.username === user.username; -class Collection extends React.Component { - constructor(props) { - super(props); - this.props.getCollections(this.props.username); - this.props.resetSorting(); - this._renderFieldHeader = this._renderFieldHeader.bind(this); - } + const hasCollection = () => !!collection; + const hasCollectionItems = () => + hasCollection() && collection.items.length > 0; - getTitle() { - if (this.hasCollection()) { - return `${this.props.t('Common.SiteName')} | ${this.getCollectionName()}`; - } - if (this.props.username === this.props.user.username) { - return this.props.t('Collection.Title'); + const getTitle = () => { + if (hasCollection()) { + return `${t('Common.SiteName')} | ${collection.name}`; } - 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; + if (username === user.username) { + return t('Collection.Title'); } + return t('Collection.AnothersTitle', { anotheruser: username }); + }; - return isOwner; - } - - hasCollection() { - return !!this.props.collection; - } - - hasCollectionItems() { - return this.hasCollection() && this.props.collection.items.length > 0; - } - - _renderLoader() { - if (this.props.loading && !this.hasCollection()) return ; - return null; - } + const renderLoader = () => (loading && !hasCollection() ? : null); - _renderEmptyTable() { - if (this.hasCollection() && !this.hasCollectionItems()) { + const renderEmptyTable = () => { + if (hasCollection() && !hasCollectionItems()) { return ( -

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

+

{t('Collection.NoSketches')}

); } return null; - } + }; - _getButtonLabel = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - let buttonLabel; + const getButtonLabel = (fieldName, displayName) => { + const { field, direction } = sorting; 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 field === 'name' + ? t('Collection.ButtonLabelAscendingARIA', { displayName }) + : t('Collection.ButtonLabelDescendingARIA', { displayName }); } - return buttonLabel; + return direction === SortingActions.DIRECTION.ASC + ? t('Collection.ButtonLabelDescendingARIA', { displayName }) + : t('Collection.ButtonLabelAscendingARIA', { displayName }); }; - _renderFieldHeader(fieldName, displayName) { - const { field, direction } = this.props.sorting; + const renderFieldHeader = (fieldName, displayName) => { + const { field, direction } = sorting; const headerClass = classNames({ arrowDown: true, 'sketches-table__header--selected': field === fieldName }); - const buttonLabel = this._getButtonLabel(fieldName, displayName); + const buttonLabel = getButtonLabel(fieldName, displayName); return ( ); - } - - render() { - const isOwner = this.isOwner(); + }; - return ( -
-
- - {this.getTitle()} - - {this._renderLoader()} - -
-
- {this._renderEmptyTable()} - {this.hasCollectionItems() && ( - - - - {this._renderFieldHeader( - 'name', - this.props.t('Collection.HeaderName') - )} - {this._renderFieldHeader( - 'createdAt', - this.props.t('Collection.HeaderCreatedAt') - )} - {this._renderFieldHeader( - 'user', - this.props.t('Collection.HeaderUser') - )} - - - - - {this.props.collection.items.map((item) => ( - - ))} - -
- )} -
-
+ return ( +
+
+ + {getTitle()} + + {renderLoader()} + +
+
+ {renderEmptyTable()} + {hasCollectionItems() && ( + + + + {renderFieldHeader('name', t('Collection.HeaderName'))} + {renderFieldHeader( + 'createdAt', + t('Collection.HeaderCreatedAt') + )} + {renderFieldHeader('user', t('Collection.HeaderUser'))} + + + + + {collection.items.map((item) => ( + + ))} + +
+ )} +
-
- ); - } -} - -Collection.propTypes = { - collectionId: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - getCollections: PropTypes.func.isRequired, - collection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - slug: PropTypes.string, - description: PropTypes.string, - owner: PropTypes.shape({ - username: PropTypes.string - }).isRequired, - items: PropTypes.arrayOf(PropTypes.shape({})) - }), - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - toggleDirectionForField: PropTypes.func.isRequired, - resetSorting: PropTypes.func.isRequired, - sorting: PropTypes.shape({ - field: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired - }).isRequired, - t: PropTypes.func.isRequired +
+
+ ); }; -Collection.defaultProps = { - username: undefined, - collection: null +Collection.propTypes = { + collectionId: PropTypes.string.isRequired }; -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collection: getCollection(state, ownProps.collectionId), - sorting: state.sorting, - loading: state.loading, - project: state.project - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(Collection) -); +export default Collection; diff --git a/client/modules/User/components/CollectionItemRow.jsx b/client/modules/User/components/CollectionItemRow.jsx new file mode 100644 index 0000000000..f34479d468 --- /dev/null +++ b/client/modules/User/components/CollectionItemRow.jsx @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import dates from '../../../utils/formatDate'; +import RemoveIcon from '../../../images/close.svg'; + +const CollectionItemRow = ({ + 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 && ( + + )} + + + ); +}; + +CollectionItemRow.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, + removeFromCollection: PropTypes.func.isRequired +}; + +export default CollectionItemRow;