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 new file mode 100644 index 0000000000..4fb7676d6b --- /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, columns))} + + ); +} + +TableBase.propTypes = { + initialSort: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired, + columns: PropTypes.arrayOf( + PropTypes.shape({ + field: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + defaultOrder: PropTypes.oneOf([DIRECTION.ASC, DIRECTION.DESC]), + formatValue: PropTypes.func + }) + ).isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired + // Will have other properties, depending on the type. + }) + ), + renderRow: PropTypes.func.isRequired, + addDropdownColumn: PropTypes.bool, + isLoading: PropTypes.bool, + emptyMessage: PropTypes.string.isRequired, + caption: PropTypes.string, + className: PropTypes.string +}; + +TableBase.defaultProps = { + items: [], + isLoading: false, + caption: '', + addDropdownColumn: false, + className: '' +}; + +export default TableBase; diff --git a/client/common/Table/TableBase.test.jsx b/client/common/Table/TableBase.test.jsx new file mode 100644 index 0000000000..d49cd395f9 --- /dev/null +++ b/client/common/Table/TableBase.test.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { DIRECTION } from '../../modules/IDE/actions/sorting'; +import { render, screen } from '../../test-utils'; +import TableBase from './TableBase'; + +describe('', () => { + const items = [ + { id: '1', name: 'abc', count: 3 }, + { id: '2', name: 'def', count: 10 } + ]; + + const props = { + items, + initialSort: { field: 'count', direction: DIRECTION.DESC }, + emptyMessage: 'No items found', + renderRow: (item) => , + columns: [] + }; + + const subject = (overrideProps) => + render(); + + jest.spyOn(props, 'renderRow'); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('shows a spinner when loading', () => { + subject({ isLoading: true }); + + expect(document.querySelector('.loader')).toBeInTheDocument(); + }); + + it('show the `emptyMessage` when there are no items', () => { + subject({ items: [] }); + + expect(screen.getByText(props.emptyMessage)).toBeVisible(); + }); + + it('calls `renderRow` function for each row', () => { + subject(); + + expect(props.renderRow).toHaveBeenCalledTimes(2); + }); + + it('sorts the items', () => { + subject(); + + expect(props.renderRow).toHaveBeenNthCalledWith(1, items[1]); + expect(props.renderRow).toHaveBeenNthCalledWith(2, items[0]); + }); + + it('does not add an extra header if `addDropdownColumn` is false', () => { + subject({ addDropdownColumn: false }); + expect(screen.queryByRole('columnheader')).not.toBeInTheDocument(); + }); + + it('adds an extra header if `addDropdownColumn` is true', () => { + subject({ addDropdownColumn: true }); + expect(screen.getByRole('columnheader')).toBeInTheDocument(); + }); +}); diff --git a/client/common/Table/TableElements.jsx b/client/common/Table/TableElements.jsx new file mode 100644 index 0000000000..83656c7488 --- /dev/null +++ b/client/common/Table/TableElements.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styled from 'styled-components'; +import { remSize } from '../../theme'; + +// eslint-disable-next-line import/prefer-default-export +export const TableEmpty = styled.p` + text-align: center; + font-size: ${remSize(16)}; + padding: ${remSize(42)} 0; +`; diff --git a/client/common/Table/TableHeaderCell.jsx b/client/common/Table/TableHeaderCell.jsx new file mode 100644 index 0000000000..99cec86eac --- /dev/null +++ b/client/common/Table/TableHeaderCell.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { DIRECTION } from '../../modules/IDE/actions/sorting'; +import { prop, remSize } from '../../theme'; +import { SortArrowDownIcon, SortArrowUpIcon } from '../icons'; + +const opposite = (direction) => + direction === DIRECTION.ASC ? DIRECTION.DESC : DIRECTION.ASC; + +const ariaSort = (direction) => + direction === DIRECTION.ASC ? 'ascending' : 'descending'; + +const TableHeaderTitle = styled.span` + border-bottom: 2px dashed transparent; + padding: ${remSize(3)} 0; + color: ${prop('inactiveTextColor')}; + ${(props) => props.selected && `border-color: ${prop('accentColor')(props)}`} +`; + +export const StyledHeaderCell = styled.th` + height: ${remSize(32)}; + position: sticky; + top: 0; + z-index: 1; + background-color: ${prop('backgroundColor')}; + font-weight: normal; + &:nth-child(1) { + padding-left: ${remSize(12)}; + } + button { + display: flex; + align-items: center; + height: ${remSize(35)}; + svg { + margin-left: ${remSize(8)}; + fill: ${prop('inactiveTextColor')}; + } + } +`; + +const TableHeaderCell = ({ sorting, field, title, defaultOrder, onSort }) => { + const isSelected = sorting.field === field; + const { direction } = sorting; + const { t } = useTranslation(); + const directionWhenClicked = isSelected ? opposite(direction) : defaultOrder; + // TODO: more generic translation properties + const translationKey = + directionWhenClicked === DIRECTION.ASC + ? 'SketchList.ButtonLabelAscendingARIA' + : 'SketchList.ButtonLabelDescendingARIA'; + const buttonLabel = t(translationKey, { + displayName: title + }); + + return ( + + + + ); +}; + +TableHeaderCell.propTypes = { + sorting: PropTypes.shape({ + field: PropTypes.string.isRequired, + direction: PropTypes.string.isRequired + }).isRequired, + field: PropTypes.string.isRequired, + title: PropTypes.string, + defaultOrder: PropTypes.oneOf([DIRECTION.ASC, DIRECTION.DESC]), + onSort: PropTypes.func.isRequired +}; + +TableHeaderCell.defaultProps = { + title: '', + defaultOrder: DIRECTION.ASC +}; + +export default TableHeaderCell; diff --git a/client/common/Table/TableHeaderCell.test.jsx b/client/common/Table/TableHeaderCell.test.jsx new file mode 100644 index 0000000000..b6bf0bda33 --- /dev/null +++ b/client/common/Table/TableHeaderCell.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { DIRECTION } from '../../modules/IDE/actions/sorting'; +import { render, fireEvent, screen } from '../../test-utils'; +import TableHeaderCell from './TableHeaderCell'; + +describe('', () => { + const onSort = jest.fn(); + + const table = document.createElement('table'); + const tr = document.createElement('tr'); + table.appendChild(tr); + document.body.appendChild(table); + + const subject = (sorting, defaultOrder = DIRECTION.ASC) => + render( + , + { container: tr } + ); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('indicates the active sort order', () => { + it('shows an up arrow when active ascending', () => { + subject({ field: 'name', direction: DIRECTION.ASC }); + expect(screen.getByRole('columnheader')).toHaveAttribute( + 'aria-sort', + 'ascending' + ); + expect(screen.getByLabelText('Ascending')).toBeVisible(); + expect(screen.queryByLabelText('Descending')).not.toBeInTheDocument(); + }); + + it('shows a down arrow when active descending', () => { + subject({ field: 'name', direction: DIRECTION.DESC }); + expect(screen.getByRole('columnheader')).toHaveAttribute( + 'aria-sort', + 'descending' + ); + expect(screen.queryByLabelText('Ascending')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Descending')).toBeVisible(); + }); + + it('has no icon when inactive', () => { + subject({ field: 'other', direction: DIRECTION.ASC }); + expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort'); + expect(screen.queryByLabelText('Ascending')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Descending')).not.toBeInTheDocument(); + }); + }); + + describe('calls onSort when clicked', () => { + const checkSort = (expectedDirection) => { + fireEvent.click(screen.getByText('Name')); + + expect(onSort).toHaveBeenCalledWith({ + field: 'name', + direction: expectedDirection + }); + }; + + it('uses defaultOrder when inactive, ascending', () => { + subject({ field: 'other', direction: DIRECTION.ASC }, DIRECTION.ASC); + checkSort(DIRECTION.ASC); + }); + + it('uses defaultOrder when inactive, descending', () => { + subject({ field: 'other', direction: DIRECTION.ASC }, DIRECTION.DESC); + checkSort(DIRECTION.DESC); + }); + + it('calls with DESC if currently sorted ASC', () => { + subject({ field: 'name', direction: DIRECTION.ASC }); + checkSort(DIRECTION.DESC); + }); + + it('calls with ASC if currently sorted DESC', () => { + subject({ field: 'name', direction: DIRECTION.DESC }); + checkSort(DIRECTION.ASC); + }); + }); +}); diff --git a/client/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/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/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..bf363ebf63 --- /dev/null +++ b/client/components/Dropdown/DropdownMenu.jsx @@ -0,0 +1,89 @@ +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 && ( + { + setTimeout(close, 100); + }} + onBlur={handleBlur} + onFocus={handleFocus} + > + {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/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/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/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index bddc2983e1..45a6ae9176 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -1,101 +1,75 @@ import PropTypes from 'prop-types'; -import React from 'react'; -import { withTranslation } from 'react-i18next'; +import React, { useCallback, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import useModalClose from '../../../common/useModalClose'; -import browserHistory from '../../../browserHistory'; 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); - } + const browserHistory = useHistory(); - 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 +77,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 +86,7 @@ Overlay.defaultProps = { title: 'Modal', closeOverlay: null, ariaLabel: 'modal', - previousPath: '/', isFixedHeight: false }; -export default withTranslation()(Overlay); +export default Overlay; 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 fc5c161fdc..97f9e82c9c 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'; @@ -170,7 +169,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 @@ -184,8 +182,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 f6cdf3abbf..523766c5e5 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'; @@ -124,10 +123,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 @@ -141,7 +136,6 @@ function mapStateToProps(state) { return { user: state.user, sketches: getSortedSketches(state), - sorting: state.sorting, loading: state.loading, project: state.project }; @@ -149,13 +143,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..eda07e36b4 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -1,129 +1,66 @@ +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 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); - }; +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 MenuItem from '../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../components/Dropdown/TableDropdown'; +import { deleteAssetRequest, getAssets } from '../actions/assets'; +import { DIRECTION } from '../actions/sorting'; - openOptions = () => { - this.setState({ - optionsOpen: true - }); - }; +const AssetMenu = ({ item: asset }) => { + const { t } = useTranslation(); - closeOptions = () => { - this.setState({ - optionsOpen: false - }); - }; + const dispatch = useDispatch(); - toggleOptions = () => { - if (this.state.optionsOpen) { - this.closeOptions(); - } else { - this.openOptions(); + const handleAssetDelete = () => { + const { key, name } = asset; + if (window.confirm(t('Common.DeleteConfirmation', { name }))) { + dispatch(deleteAssetRequest(key)); } }; - handleDropdownOpen = () => { - this.closeOptions(); - this.openOptions(); - }; + return ( + + {t('AssetList.Delete')} + + {t('AssetList.OpenNewTab')} + + + ); +}; - handleAssetDelete = () => { - const { key, name } = this.props.asset; - this.closeOptions(); - if (window.confirm(this.props.t('Common.DeleteConfirmation', { name }))) { - this.props.deleteAssetRequest(key); - } - }; +AssetMenu.propTypes = { + item: PropTypes.shape({ + key: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired +}; - render() { - const { asset, username, t } = this.props; - const { optionsOpen } = this.state; - return ( - - - - {asset.name} - - - {prettyBytes(asset.size)} - - {asset.sketchId && ( - - {asset.sketchName} - - )} - - - - {optionsOpen && ( -
      -
    • - -
    • -
    • - - {t('AssetList.OpenNewTab')} - -
    • -
    - )} - - - ); - } -} +const AssetListRowBase = ({ asset, username }) => ( + + + + {asset.name} + + + {prettyBytes(asset.size)} + + {asset.sketchId && ( + + {asset.sketchName} + + )} + + + + + +); AssetListRowBase.propTypes = { asset: PropTypes.shape({ @@ -134,9 +71,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) { @@ -145,106 +80,67 @@ function mapStateToPropsAssetListRow(state) { }; } -function mapDispatchToPropsAssetListRow(dispatch) { - return bindActionCreators(AssetActions, dispatch); -} - -const AssetListRow = connect( - mapStateToPropsAssetListRow, - mapDispatchToPropsAssetListRow -)(AssetListRowBase); - -class AssetList extends React.Component { - constructor(props) { - super(props); - this.props.getAssets(); - } - - getAssetsTitle() { - return this.props.t('AssetList.Title'); - } - - hasAssets() { - return !this.props.loading && this.props.assetList.length > 0; - } - - renderLoader() { - if (this.props.loading) return ; - return null; - } - - renderEmptyTable() { - if (!this.props.loading && this.props.assetList.length === 0) { - return ( -

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

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

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

    - ); - } - return null; - } - - _getButtonLabel = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - let buttonLabel; - if (field !== fieldName) { - if (field === 'name') { - buttonLabel = this.props.t('CollectionList.ButtonLabelAscendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('CollectionList.ButtonLabelDescendingARIA', { - displayName - }); - } - } else if (direction === SortingActions.DIRECTION.ASC) { - buttonLabel = this.props.t('CollectionList.ButtonLabelDescendingARIA', { - displayName - }); - } else { - buttonLabel = this.props.t('CollectionList.ButtonLabelAscendingARIA', { - displayName - }); - } - return buttonLabel; - }; - - _renderFieldHeader = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - const headerClass = classNames({ - 'sketches-table__header': true, - 'sketches-table__header--selected': field === fieldName - }); - const buttonLabel = this._getButtonLabel(fieldName, displayName); - return ( - - - - ); - }; - - render() { - const username = - this.props.username !== undefined - ? this.props.username - : this.props.user.username; - const { mobile } = this.props; - - return ( -
    - - {this.getTitle()} - - - {this._renderLoader()} - {this._renderEmptyTable()} - {this.hasCollections() && ( - - - - {this._renderFieldHeader( - 'name', - this.props.t('CollectionList.HeaderName') - )} - {this._renderFieldHeader( - 'createdAt', - this.props.t('CollectionList.HeaderCreatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {this._renderFieldHeader( - 'updatedAt', - this.props.t('CollectionList.HeaderUpdatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {this._renderFieldHeader( - 'numItems', - this.props.t('CollectionList.HeaderNumItems', { - context: mobile ? 'mobile' : '' - }) - )} - - - - - {this.props.collections.map((collection) => ( - this.showAddSketches(collection.id)} - /> - ))} - -
    - )} - {this.state.addingSketchesToCollectionId && ( - } - closeOverlay={this.hideAddSketches} - isFixedHeight - > - { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const collections = useSelector(getSortedCollections); + + // TODO: combine with AddToCollectionList + const loading = useSelector((state) => state.loading); + const [hasLoadedData, setHasLoadedData] = useState(false); + const showLoader = loading && !hasLoadedData; + + useEffect(() => { + dispatch(getCollections(username)).then(() => setHasLoadedData(true)); + }, [dispatch, username]); + + const currentUser = useSelector(selectCurrentUsername); + const userIsOwner = username === currentUser; + + const [ + addingSketchesToCollectionId, + setAddingSketchesToCollectionId + ] = useState(null); + + return ( +
    + + + {userIsOwner + ? t('CollectionList.Title') + : t('CollectionList.AnothersTitle', { + anotheruser: username })} - /> - </Overlay> + + + + ( + setAddingSketchesToCollectionId(collection.id)} + /> )} -
    - ); - } -} + /> + {addingSketchesToCollectionId && ( + } + closeOverlay={() => setAddingSketchesToCollectionId(null)} + isFixedHeight + > + + + )} +
    + ); +}; CollectionList.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - projectId: PropTypes.string, - getCollections: PropTypes.func.isRequired, - getProject: PropTypes.func.isRequired, - collections: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - description: PropTypes.string, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired - }) - ).isRequired, - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - toggleDirectionForField: PropTypes.func.isRequired, - resetSorting: PropTypes.func.isRequired, - sorting: PropTypes.shape({ - field: PropTypes.string.isRequired, - direction: PropTypes.string.isRequired - }).isRequired, - project: PropTypes.shape({ - id: PropTypes.string, - owner: PropTypes.shape({ - id: PropTypes.string - }) - }), - t: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, mobile: PropTypes.bool }; CollectionList.defaultProps = { - projectId: undefined, - project: { - id: undefined, - owner: undefined - }, - username: undefined, mobile: false }; -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collections: getSortedCollections(state), - sorting: state.sorting, - loading: state.loading, - project: state.project, - projectId: ownProps && ownProps.params ? ownProps.params.project_id : null - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ProjectActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(CollectionList) -); +export default CollectionList; diff --git a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index ed109141d7..4e401b316c 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; + const { userIsOwner } = this.props; return ( - - - {optionsOpen && ( -
      -
    • - -
    • - {userIsOwner && ( -
    • - -
    • - )} - {userIsOwner && ( -
    • - -
    • - )} -
    + + > + + {this.props.t('CollectionListRow.AddSketch')} + + + {this.props.t('CollectionListRow.Delete')} + + + {this.props.t('CollectionListRow.Rename')} + + ); }; @@ -264,10 +177,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/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/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index af03453e0d..2512553047 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -1,149 +1,58 @@ 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 { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { bindActionCreators } from 'redux'; -import classNames from 'classnames'; import slugify from 'slugify'; + +import TableWithRename from '../../../common/Table/TableWithRename'; +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 { + changeProjectName, + cloneProject, + deleteProject +} from '../actions/project'; +import { getProjects } from '../actions/projects'; +import { DIRECTION } from '../actions/sorting'; +import getSortedSketches from '../selectors/projects'; +import { getAuthenticated, 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'); const formatDateCell = (date, mobile = false) => dates.format(date, { showTime: !mobile }); -class SketchListRowBase extends React.Component { - constructor(props) { - super(props); - this.state = { - optionsOpen: false, - renameOpen: false, - renameValue: props.sketch.name, - isFocused: false - }; - 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}`; +}; - 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( - { - renameOpen: true, - renameValue: this.props.sketch.name - }, - () => this.renameInput.current.focus() - ); - }; - - closeRename = () => { - this.setState({ - renameOpen: false - }); - }; - - closeAll = () => { - this.setState({ - renameOpen: false, - optionsOpen: false - }); - }; - - handleRenameChange = (e) => { - this.setState({ - renameValue: e.target.value - }); - }; +const SketchDropdown = ({ + row: sketch, + onClickRename, + ownerUsername, + setSketchToAddToCollection +}) => { + const { t } = useTranslation(); - handleRenameEnter = (e) => { - if (e.key === 'Enter') { - this.updateName(); - this.closeAll(); - } - }; + const dispatch = useDispatch(); - handleRenameBlur = () => { - this.updateName(); - this.closeAll(); - }; + const isAuthenticated = useSelector(getAuthenticated); - updateName = () => { - const isValid = this.state.renameValue.trim().length !== 0; - if (isValid) { - this.props.changeProjectName( - this.props.sketch.id, - this.state.renameValue.trim() - ); - } - }; + const currentUser = useSelector((state) => state.user.username); - resetSketchName = () => { - this.setState({ - renameValue: this.props.sketch.name, - renameOpen: false - }); - }; - - handleDropdownOpen = () => { - this.closeAll(); - this.openOptions(); - }; + const userIsOwner = ownerUsername === currentUser; - handleRenameOpen = () => { - this.closeAll(); - this.openRename(); - }; - - 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`; @@ -152,464 +61,174 @@ class SketchListRowBase extends React.Component { document.body.removeChild(downloadLink); }; - handleSketchDuplicate = () => { - this.closeAll(); - this.props.cloneProject(this.props.sketch); + const handleSketchDuplicate = () => { + dispatch(cloneProject(sketch)); }; - handleSketchShare = () => { - this.closeAll(); - this.props.showShareModal( - this.props.sketch.id, - this.props.sketch.name, - this.props.username - ); - }; - - handleSketchDelete = () => { - this.closeAll(); + 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)); } }; - renderViewButton = (sketchURL) => ( - - {this.props.t('SketchList.View')} - + return ( + + + {t('SketchList.DropdownRename')} + + + {t('SketchList.DropdownDownload')} + + + {t('SketchList.DropdownDuplicate')} + + { + setSketchToAddToCollection(sketch); + }} + > + {t('SketchList.DropdownAddToCollection')} + + + {/* + + Share + + */} + + {t('SketchList.DropdownDelete')} + + ); +}; - renderDropdown = () => { - const { optionsOpen } = this.state; - const userIsOwner = this.props.user.username === this.props.username; - - return ( - - - {optionsOpen && ( -
      - {userIsOwner && ( -
    • - -
    • - )} -
    • - -
    • - {this.props.user.authenticated && ( -
    • - -
    • - )} - {this.props.user.authenticated && ( -
    • - -
    • - )} - {/*
    • - -
    • */} - {userIsOwner && ( -
    • - -
    • - )} -
    - )} - - ); - }; - - 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 mapDispatchToPropsSketchListRow(dispatch) { - return bindActionCreators( - Object.assign({}, ProjectActions, IdeActions), - dispatch - ); -} - -const SketchListRow = connect( - null, - 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 - }); - } - } +const SketchList = ({ username, mobile }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); - 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 - }); - } + const currentUser = useSelector(selectCurrentUsername); - hasSketches() { - return !this.isLoading() && this.props.sketches.length > 0; - } + const sketches = useSelector(getSortedSketches); - isLoading() { - return this.props.loading && this.state.isInitialDataLoad; - } + // TODO: combine with AddToCollectionSketchList + const loading = useSelector((state) => state.loading); + const [hasLoadedData, setHasLoadedData] = useState(false); + const showLoader = loading && !hasLoadedData; - _renderLoader() { - if (this.isLoading()) return ; - return null; - } + useEffect(() => { + dispatch(getProjects(username)).then(() => setHasLoadedData(true)); + }, [dispatch, username]); - _renderEmptyTable() { - if (!this.isLoading() && this.props.sketches.length === 0) { - return ( -

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

    - ); - } - return null; - } + const [sketchToAddToCollection, setSketchToAddToCollection] = useState(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 - }); + const handleRename = (newName, sketchId) => { + const isValid = newName.trim().length !== 0; + if (isValid) { + dispatch(changeProjectName(sketchId, newName.trim())); } - return buttonLabel; }; - _renderFieldHeader = (fieldName, displayName) => { - const { field, direction } = this.props.sorting; - const headerClass = classNames({ - 'sketches-table__header': true, - 'sketches-table__header--selected': field === fieldName - }); - const buttonLabel = this._getButtonLabel(fieldName, displayName); - return ( - - - - ); - }; - - render() { - const username = - this.props.username !== undefined - ? this.props.username - : this.props.user.username; - const { mobile } = this.props; - return ( -
    - - {this.getSketchesTitle()} - - {this._renderLoader()} - {this._renderEmptyTable()} - {this.hasSketches() && ( - - - - {this._renderFieldHeader( - 'name', - this.props.t('SketchList.HeaderName') - )} - {this._renderFieldHeader( - 'createdAt', - this.props.t('SketchList.HeaderCreatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - {this._renderFieldHeader( - 'updatedAt', - this.props.t('SketchList.HeaderUpdatedAt', { - context: mobile ? 'mobile' : '' - }) - )} - - - - - {this.props.sketches.map((sketch) => ( - { - this.setState({ sketchToAddToCollection: sketch }); - }} - t={this.props.t} - /> - ))} - -
    - )} - {this.state.sketchToAddToCollection && ( - - this.setState({ sketchToAddToCollection: null }) - } - > - - - )} -
    - ); - } -} + + + )} + + ); +}; 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..46749f7ee3 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`] = ` @@ -85,15 +91,19 @@ exports[` snapshot testing 1`] = ` - + +
    snapshot testing 1`] = ` - + + diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 19b1da2ce2..4a095aa429 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -227,14 +227,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(); - } } } @@ -499,8 +491,6 @@ IDEView.propTypes = { expandConsole: PropTypes.func.isRequired, collapseConsole: PropTypes.func.isRequired, updateFileContent: PropTypes.func.isRequired, - closeNewFolderModal: PropTypes.func.isRequired, - closeNewFileModal: PropTypes.func.isRequired, closeShareModal: PropTypes.func.isRequired, closeKeyboardShortcutModal: PropTypes.func.isRequired, autosaveProject: PropTypes.func.isRequired, @@ -509,7 +499,6 @@ IDEView.propTypes = { hideErrorModal: PropTypes.func.isRequired, clearPersistedState: PropTypes.func.isRequired, startSketch: PropTypes.func.isRequired, - closeUploadFileModal: PropTypes.func.isRequired, t: PropTypes.func.isRequired, isUserOwner: PropTypes.bool.isRequired }; 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..5d70e2463a 100644 --- a/client/modules/User/components/Collection.jsx +++ b/client/modules/User/components/Collection.jsx @@ -1,85 +1,26 @@ import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } 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 * 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 { useTranslation } from 'react-i18next'; +import TableBase from '../../../common/Table/TableBase'; +import { + getCollections, + removeFromCollection +} from '../../IDE/actions/collections'; +import { DIRECTION } from '../../IDE/actions/sorting'; 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'; +import CollectionMetadata from './CollectionMetadata'; -const ShareURL = ({ value }) => { - const [showURL, setShowURL] = useState(false); - const node = useRef(); +const CollectionItemRow = ({ collection, item, isOwner }) => { 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 dispatch = useDispatch(); const projectIsDeleted = item.isDeleted; @@ -91,7 +32,7 @@ const CollectionItemRowBase = ({ t('Collection.DeleteFromCollection', { name_sketch: name }) ) ) { - removeFromCollection(collection.id, item.projectId); + dispatch(removeFromCollection(collection.id, item.projectId)); } }; @@ -129,7 +70,7 @@ const CollectionItemRowBase = ({ ); }; -CollectionItemRowBase.propTypes = { +CollectionItemRow.propTypes = { collection: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired @@ -146,408 +87,106 @@ CollectionItemRowBase.propTypes = { }) }).isRequired }).isRequired, - isOwner: PropTypes.bool.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - removeFromCollection: PropTypes.func.isRequired + isOwner: PropTypes.bool.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 - }); - } +const Collection = ({ username, collectionId }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); - getUsername() { - return this.props.username !== undefined - ? this.props.username - : this.props.user.username; - } + const collection = useSelector((state) => getCollection(state, collectionId)); - getCollectionName() { - return this.props.collection.name; - } + const loading = useSelector((state) => state.loading); + const showLoader = loading && !collection; - isOwner() { - let isOwner = false; + const currentUsername = useSelector((state) => state.user.username); + const isOwner = username === currentUsername; - if ( - this.props.user != null && - this.props.user.username && - this.props.collection.owner.username === this.props.user.username - ) { - isOwner = true; + useEffect(() => { + if (!collection) { + dispatch(getCollections(username)); } + }, [username, collection]); + + // Need top-level string fields in order to sort. + const items = useMemo( + () => + 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 + })), + [collection] + ); - 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() ? ( - 0 ? 'true' : 'false'} + > +

    + + + {isOwner + ? t('Collection.Title') + : t('Collection.AnothersTitle', { + anotheruser: username + })} + + + {showLoader && } + {collection && ( + + )} +
    +
    + ( + - ) : ( - 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 - } - } +Collection.propTypes = { + collectionId: PropTypes.string.isRequired, + username: PropTypes.string.isRequired }; -function mapStateToProps(state, ownProps) { - return { - user: state.user, - collection: getCollection(state, ownProps.collectionId), - sorting: state.sorting, - loading: state.loading, - project: state.project - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(Collection) -); +export default Collection; diff --git a/client/modules/User/components/CollectionMetadata.jsx b/client/modules/User/components/CollectionMetadata.jsx new file mode 100644 index 0000000000..9d6df781d2 --- /dev/null +++ b/client/modules/User/components/CollectionMetadata.jsx @@ -0,0 +1,129 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import Button from '../../../common/Button'; +import Overlay from '../../App/components/Overlay'; +import { editCollection } from '../../IDE/actions/collections'; +import AddToCollectionSketchList from '../../IDE/components/AddToCollectionSketchList'; +import EditableInput from '../../IDE/components/EditableInput'; +import { SketchSearchbar } from '../../IDE/components/Searchbar'; +import ShareURL from './CollectionShareButton'; + +function CollectionMetadata({ collection, isOwner }) { + const { t } = useTranslation(); + + const dispatch = useDispatch(); + + const currentUsername = useSelector((state) => state.user.username); + + const [isAddingSketches, setIsAddingSketches] = useState(false); + + const { id, name, description, items, owner } = collection; + const { username: ownerUsername } = owner; + + const hostname = window.location.origin; + + const handleEditCollectionName = (value) => { + if (value === name) { + return; + } + dispatch(editCollection(id, { name: value })); + }; + + const handleEditCollectionDescription = (value) => { + if (value === description) { + return; + } + dispatch(editCollection(id, { description: value })); + }; + + // TODO: Implement UI for editing slug + + return ( +
    +
    +
    +

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

    + +

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

    + +

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

    + +

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

    +
    + +
    + + {isOwner && ( + + )} +
    +
    + {isAddingSketches && ( + } + closeOverlay={() => setIsAddingSketches(false)} + isFixedHeight + > + + + )} +
    + ); +} + +CollectionMetadata.propTypes = { + 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; diff --git a/client/modules/User/components/CollectionShareButton.jsx b/client/modules/User/components/CollectionShareButton.jsx new file mode 100644 index 0000000000..c4e0bba915 --- /dev/null +++ b/client/modules/User/components/CollectionShareButton.jsx @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Button from '../../../common/Button'; +import { DropdownArrowIcon } from '../../../common/icons'; +import CopyableInput from '../../IDE/components/CopyableInput'; + +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 +}; + +export default ShareURL; diff --git a/client/reducers.js b/client/reducers.js index f61d2585d6..e95a1d5061 100644 --- a/client/reducers.js +++ b/client/reducers.js @@ -10,7 +10,6 @@ import toast from './modules/IDE/reducers/toast'; import console from './modules/IDE/reducers/console'; import assets from './modules/IDE/reducers/assets'; import search from './modules/IDE/reducers/search'; -import sorting from './modules/IDE/reducers/sorting'; import loading from './modules/IDE/reducers/loading'; import collections from './modules/IDE/reducers/collections'; @@ -22,7 +21,6 @@ const rootReducer = combineReducers({ project, sketches, search, - sorting, editorAccessibility, toast, console, diff --git a/client/styles/components/_asset-list.scss b/client/styles/components/_asset-list.scss index 6f7c035993..531e3944fe 100644 --- a/client/styles/components/_asset-list.scss +++ b/client/styles/components/_asset-list.scss @@ -5,28 +5,7 @@ } .asset-table { - width: 100%; - - max-height: 100%; - border-spacing: 0; position: relative; - & .asset-table__dropdown-column { - width: #{60 / $base-font-size}rem; - position: relative; - } -} - -.asset-table thead th { - height: #{32 / $base-font-size}rem; - position: sticky; - top: 0; - @include themify() { - background-color: getThemifyVariable('background-color'); - } -} - -.asset-table thead th:nth-child(1){ - padding-left: #{12 / $base-font-size}rem; } .asset-table__row { @@ -51,23 +30,6 @@ } } -.asset-table thead { - font-size: #{12 / $base-font-size}rem; - @include themify() { - color: getThemifyVariable('inactive-text-color') - } -} - -.asset-table th { - font-weight: normal; -} - -.asset-table__empty { - text-align: center; - font-size: #{16 / $base-font-size}rem; - padding: #{42 / $base-font-size}rem 0; -} - .asset-table__total { padding: 0 #{20 / $base-font-size}rem; position: sticky; @@ -76,24 +38,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/_collection.scss b/client/styles/components/_collection.scss index 0c8707ecf6..e1062739a7 100644 --- a/client/styles/components/_collection.scss +++ b/client/styles/components/_collection.scss @@ -127,11 +127,6 @@ align-items: center; } -.collection-empty-message { - text-align: center; - font-size: #{16 / $base-font-size}rem; -} - .collection-row__action-column { width: #{60 / $base-font-size}rem; position: relative; diff --git a/client/styles/components/_sketch-list.scss b/client/styles/components/_sketch-list.scss index b03381b091..a2460f599d 100644 --- a/client/styles/components/_sketch-list.scss +++ b/client/styles/components/_sketch-list.scss @@ -15,50 +15,6 @@ } } -.sketches-table thead th { - height: #{32 / $base-font-size}rem; - position: sticky; - top: 0; - z-index: 1; - @include themify() { - background-color: getThemifyVariable('background-color'); - } -} - -.sketch-list__sort-button { - display: flex; - align-items: center; - height: #{35 / $base-font-size}rem; - - & .isvg { - margin-left: #{8 / $base-font-size}rem; - } - - & svg { - @include themify() { - fill: getThemifyVariable('inactive-text-color') - } - } -} - -.sketches-table__header { - border-bottom: 2px dashed transparent; - padding: #{3 / $base-font-size}rem 0; - @include themify() { - color: getThemifyVariable('inactive-text-color') - } -} - -.sketches-table__header--selected { - @include themify() { - border-color: getThemifyVariable('logo-color'); - } -} - -.sketches-table thead th:nth-child(1){ - padding-left: #{12 / $base-font-size}rem; -} - .sketches-table__row { margin: #{10 / $base-font-size}rem; height: #{72 / $base-font-size}rem; @@ -100,17 +56,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; @@ -119,15 +64,3 @@ .sketches-table__icon-cell { 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; - padding: #{42 / $base-font-size}rem 0; -} diff --git a/client/theme.js b/client/theme.js index 7d3dad8fab..ed8af098ae 100644 --- a/client/theme.js +++ b/client/theme.js @@ -74,6 +74,7 @@ const baseThemes = { primaryTextColor: grays.dark, inactiveTextColor: grays.middleDark, backgroundColor: grays.lighter, + accentColor: colors.p5jsPink, Button: { primary: { @@ -156,6 +157,7 @@ const baseThemes = { primaryTextColor: grays.lightest, inactiveTextColor: grays.middleLight, backgroundColor: grays.darker, + accentColor: colors.p5jsPink, Button: { primary: { @@ -238,6 +240,7 @@ export default { ...baseThemes, [Theme.contrast]: extend(baseThemes[Theme.dark], { inactiveTextColor: grays.light, + accentColor: colors.yellow, Button: { primary: {