diff --git a/.storybook/decorator-theme.js b/.storybook/decorator-theme.js new file mode 100644 index 0000000000..c3a60f8386 --- /dev/null +++ b/.storybook/decorator-theme.js @@ -0,0 +1,53 @@ +import { upperFirst } from 'lodash'; +import React from 'react'; +import styled, { ThemeProvider } from 'styled-components'; +import theme, { prop } from '../client/theme'; + +const PreviewArea = styled.div` + background: ${prop('backgroundColor')}; + flex-grow: 1; + padding: 2rem; + & > h4 { + margin-top: 0; + color: ${prop('primaryTextColor')}; + } +`; + +const themeKeys = Object.keys(theme); + +export const withThemeProvider = (Story, context) => { + const setting = context.globals.theme; + if (setting === 'all') { + return ( +
+ {Object.keys(theme).map((themeName) => ( + + +

{upperFirst(themeName)}

+ +
+
+ ))} +
+ ); + } else { + const themeName = setting; + return ( + + + + + + ); + } +}; + +export const themeToolbarItem = { + description: 'Global theme for components', + defaultValue: 'all', + toolbar: { + title: 'Theme', + icon: 'mirror', + items: [...themeKeys, 'all'] + } +}; diff --git a/.storybook/preview.js b/.storybook/preview.js index 8c29a24faa..9260b91c98 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -2,10 +2,10 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router'; -import ThemeProvider from '../client/modules/App/components/ThemeProvider'; import configureStore from '../client/store'; import '../client/i18n-test'; import '../client/styles/storybook.css' +import { withThemeProvider, themeToolbarItem } from './decorator-theme'; const initialState = window.__INITIAL_STATE__; @@ -21,5 +21,9 @@ export const decorators = [ ), -] + withThemeProvider +]; +export const globalTypes = { + theme: themeToolbarItem +}; diff --git a/README.md b/README.md index 653246e986..75345a98e3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ If you have found a bug in the p5.js Web Editor, you can file it under the ["iss ### How Do I Know My Issue or Pull Request is Getting Reviewed? -To see which pull requests and issues are currently being reviewed, check the [PR Review Board](https://github.com/processing/p5.js-web-editor/projects/9) or the following Milestones: [PATCH Release](https://github.com/processing/p5.js-web-editor/milestone/10), [MINOR Release](https://github.com/processing/p5.js-web-editor/milestone/8). +To see which pull requests and issues are currently being reviewed, check the [PR Review Board](https://github.com/processing/p5.js-web-editor/projects/9) or the following Milestones: [MINOR Release](https://github.com/processing/p5.js-web-editor/milestone/8). Issues and Pull Requests categorized under the PATCH or MINOR Release Milestones will be prioritized since they are planned to be merged for the next release to Production. Please feel free to [comment on this pinned issue](https://github.com/processing/p5.js-web-editor/issues/2534) if you would like your issue to be considered for the next release! @@ -38,9 +38,7 @@ Issues and Pull Requests categorized under the PATCH or MINOR Release Milestones We will aim to deploy on a 1-2 month basis. Here are some dates we’re working towards: -2.9.3 PATCH Release: By November 17, 2023 - -2.10.0 MINOR Release: By December 15, 2023 +2.11.0 MINOR Release: By January 16, 2023 [You can read more about Semantic Versioning and the differences between a MINOR and PATCH release](https://semver.org/). diff --git a/client/common/Button.jsx b/client/common/Button.jsx index d6dd18491e..516536fb2b 100644 --- a/client/common/Button.jsx +++ b/client/common/Button.jsx @@ -21,7 +21,8 @@ const displays = { const StyledButton = styled.button` &&& { font-weight: bold; - display: flex; + display: ${({ display }) => + display === displays.inline ? 'inline-flex' : 'flex'}; justify-content: center; align-items: center; @@ -107,57 +108,6 @@ const StyledInlineButton = styled.button` } `; -const StyledIconButton = styled.button` - &&& { - display: flex; - justify-content: center; - align-items: center; - - width: ${remSize(32)}px; - height: ${remSize(32)}px; - text-decoration: none; - - color: ${({ kind }) => prop(`Button.${kind}.default.foreground`)}; - background-color: ${({ kind }) => prop(`Button.${kind}.hover.background`)}; - cursor: pointer; - border: 1px solid transparent; - border-radius: 50%; - padding: ${remSize(8)} ${remSize(25)}; - line-height: 1; - - &:hover:not(:disabled) { - color: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.hover.background`)}; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.hover.foreground`)}; - } - } - - &:active:not(:disabled) { - color: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.active.background`)}; - - svg * { - fill: ${({ kind }) => prop(`Button.${kind}.active.foreground`)}; - } - } - - &:disabled { - color: ${({ kind }) => prop(`Button.${kind}.disabled.foreground`)}; - background-color: ${({ kind }) => - prop(`Button.${kind}.disabled.background`)}; - cursor: not-allowed; - } - - > * + * { - margin-left: ${remSize(8)}; - } - } -`; - /** * A Button performs an primary action */ @@ -184,12 +134,8 @@ const Button = ({ ); let StyledComponent = StyledButton; - if (display === displays.inline) { - StyledComponent = StyledInlineButton; - } - if (iconOnly) { - StyledComponent = StyledIconButton; + StyledComponent = StyledInlineButton; } if (href) { @@ -265,7 +211,7 @@ Button.propTypes = { /** * The display type of the button—inline or block */ - display: PropTypes.string, + display: PropTypes.oneOf(Object.values(displays)), /** * SVG icon to place after child content */ @@ -286,7 +232,7 @@ Button.propTypes = { * Specifying an href will use an to link to the URL */ href: PropTypes.string, - /* + /** * An ARIA Label used for accessibility */ 'aria-label': PropTypes.string, diff --git a/client/common/IconButton.jsx b/client/common/IconButton.jsx index ca5e3f6973..8cf732f91d 100644 --- a/client/common/IconButton.jsx +++ b/client/common/IconButton.jsx @@ -19,6 +19,7 @@ const IconButton = (props) => { return ( } + iconOnly display={Button.displays.inline} focusable="false" {...otherProps} diff --git a/client/common/RouterTab.jsx b/client/common/RouterTab.jsx new file mode 100644 index 0000000000..d08c839855 --- /dev/null +++ b/client/common/RouterTab.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +/** + * Wraps the react-router `NavLink` with dashboard-header__tab styling. + */ +const Tab = ({ children, to }) => ( +
  • + + {children} + +
  • +); + +Tab.propTypes = { + children: PropTypes.string.isRequired, + to: PropTypes.string.isRequired +}; + +export default Tab; diff --git a/client/modules/IDE/hooks/useKeyDownHandlers.js b/client/common/useKeyDownHandlers.js similarity index 100% rename from client/modules/IDE/hooks/useKeyDownHandlers.js rename to client/common/useKeyDownHandlers.js diff --git a/client/common/useModalClose.js b/client/common/useModalClose.js new file mode 100644 index 0000000000..2bab24b5de --- /dev/null +++ b/client/common/useModalClose.js @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; +import useKeyDownHandlers from './useKeyDownHandlers'; + +/** + * 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 handleClick(e) { + // ignore clicks on the component itself + if (modalRef.current && !modalRef.current.contains(e.target)) { + onClose?.(); + } + } + + document.addEventListener('click', handleClick, false); + + return () => { + document.removeEventListener('click', handleClick, false); + }; + }, [onClose, modalRef]); + + useKeyDownHandlers({ escape: onClose }); + + return modalRef; +} diff --git a/client/components/Dropdown.jsx b/client/components/Dropdown.jsx index 7f51284179..c04f961d75 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 '../common/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..da41b30101 --- /dev/null +++ b/client/components/Dropdown/DropdownMenu.jsx @@ -0,0 +1,96 @@ +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, anchor, '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, 0); + }} + onBlur={handleBlur} + onFocus={handleFocus} + > + {children} + + )} +
    + ); + } +); + +DropdownMenu.propTypes = { + /** + * Provide elements as children to control the contents of the menu. + */ + children: PropTypes.node.isRequired, + /** + * Can optionally override the contents of the button which opens the menu. + * Defaults to + */ + anchor: 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 = { + anchor: 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..d4db78f963 --- /dev/null +++ b/client/components/Dropdown/TableDropdown.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useMediaQuery } from 'react-responsive'; +import styled from 'styled-components'; +import { prop, remSize } from '../../theme'; +import DropdownMenu from './DropdownMenu'; + +import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg'; +import MoreIconSvg from '../../images/more.svg'; + +const DotsHorizontal = styled(MoreIconSvg)` + transform: rotate(90deg); +`; + +const TableDropdownIcon = () => { + // TODO: centralize breakpoints + const isMobile = useMediaQuery({ maxWidth: 770 }); + + return isMobile ? ( +
    -
    -
    { - this.node = node; - }} - className="overlay__body" - > -
    -

    {title}

    -
    - {actions} - -
    -
    - {children} - this.close() }} /> -
    -
    + return ( +
    +
    +
    +
    +

    {title}

    +
    + {actions} + +
    +
    + {children} +
    - ); - } -} +
    + ); +}; Overlay.propTypes = { children: PropTypes.element, @@ -94,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 = { @@ -105,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/assets.js b/client/modules/IDE/actions/assets.js index 0a7a13e3a0..333dfd0ab2 100644 --- a/client/modules/IDE/actions/assets.js +++ b/client/modules/IDE/actions/assets.js @@ -1,21 +1,22 @@ import apiClient from '../../../utils/apiClient'; import * as ActionTypes from '../../../constants'; import { startLoader, stopLoader } from './loader'; +import { assetsActions } from '../reducers/assets'; -function setAssets(assets, totalSize) { - return { - type: ActionTypes.SET_ASSETS, - assets, - totalSize - }; -} +const { setAssets, deleteAsset } = assetsActions; export function getAssets() { return async (dispatch) => { dispatch(startLoader()); try { const response = await apiClient.get('/S3/objects'); - dispatch(setAssets(response.data.assets, response.data.totalSize)); + + const assetData = { + assets: response.data.assets, + totalSize: response.data.totalSize + }; + + dispatch(setAssets(assetData)); dispatch(stopLoader()); } catch (error) { dispatch({ @@ -26,13 +27,6 @@ export function getAssets() { }; } -export function deleteAsset(assetKey) { - return { - type: ActionTypes.DELETE_ASSET, - key: assetKey - }; -} - export function deleteAssetRequest(assetKey) { return async (dispatch) => { try { diff --git a/client/modules/IDE/actions/collections.js b/client/modules/IDE/actions/collections.js index 24b1ae3797..e8bda9623f 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({ diff --git a/client/modules/IDE/components/About.jsx b/client/modules/IDE/components/About.jsx index e9a17f141d..d22ec2c631 100644 --- a/client/modules/IDE/components/About.jsx +++ b/client/modules/IDE/components/About.jsx @@ -28,7 +28,7 @@ function About(props) { />

    - Web Editor: v{packageData?.version} + {t('About.WebEditor')}: v{packageData?.version}

    p5.js: v{p5version} @@ -44,7 +44,7 @@ function About(props) { aria-hidden="true" focusable="false" /> - Home + {t('About.Home')}

    @@ -86,7 +86,7 @@ function About(props) { aria-hidden="true" focusable="false" /> - Twitter + {t('About.Twitter')}

    @@ -100,7 +100,7 @@ function About(props) { aria-hidden="true" focusable="false" /> - Instagram + {t('About.Instagram')}

    @@ -159,7 +159,7 @@ function About(props) { aria-hidden="true" focusable="false" /> - Discord + {t('About.Discord')}

    diff --git a/client/modules/IDE/components/AddToCollectionList.jsx b/client/modules/IDE/components/AddToCollectionList.jsx index fc5c161fdc..ecb020ce6f 100644 --- a/client/modules/IDE/components/AddToCollectionList.jsx +++ b/client/modules/IDE/components/AddToCollectionList.jsx @@ -1,23 +1,19 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; -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 { + addToCollection, + getCollections, + removeFromCollection +} from '../actions/collections'; +import getSortedCollections from '../selectors/collections'; import QuickAddList from './QuickAddList'; import { remSize } from '../../../theme'; -const projectInCollection = (project, collection) => - collection.items.find((item) => item.projectId === project.id) != null; - export const CollectionAddSketchWrapper = styled.div` width: ${remSize(600)}; max-width: 100%; @@ -31,166 +27,67 @@ export const QuickAddWrapper = styled.div` height: 100%; `; -class CollectionList extends React.Component { - constructor(props) { - super(props); +const AddToCollectionList = ({ projectId }) => { + const { t } = useTranslation(); - if (props.projectId) { - props.getProject(props.projectId); - } + const dispatch = useDispatch(); - this.props.getCollections(this.props.username); + const username = useSelector((state) => state.user.username); - this.state = { - hasLoadedData: false - }; - } + const collections = useSelector(getSortedCollections); - componentDidUpdate(prevProps) { - if (prevProps.loading === true && this.props.loading === false) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - hasLoadedData: true - }); - } - } + // TODO: improve loading state + const loading = useSelector((state) => state.loading); + const [hasLoadedData, setHasLoadedData] = useState(false); + const showLoader = loading && !hasLoadedData; - getTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('AddToCollectionList.Title'); - } - return this.props.t('AddToCollectionList.AnothersTitle', { - anotheruser: this.props.username - }); - } + useEffect(() => { + dispatch(getCollections(username)).then(() => setHasLoadedData(true)); + }, [dispatch, username]); - handleCollectionAdd = (collection) => { - this.props.addToCollection(collection.id, this.props.project.id); + const handleCollectionAdd = (collection) => { + dispatch(addToCollection(collection.id, projectId)); }; - handleCollectionRemove = (collection) => { - this.props.removeFromCollection(collection.id, this.props.project.id); + const handleCollectionRemove = (collection) => { + dispatch(removeFromCollection(collection.id, projectId)); }; - render() { - const { collections, project } = this.props; - const hasCollections = collections.length > 0; - const collectionWithSketchStatus = collections.map((collection) => ({ - ...collection, - url: `/${collection.owner.username}/collections/${collection.id}`, - isAdded: projectInCollection(project, collection) - })); - - let content = null; - - if (this.props.loading && !this.state.hasLoadedData) { - content = ; - } else if (hasCollections) { - content = ( - - ); - } else { - content = this.props.t('AddToCollectionList.Empty'); + const collectionWithSketchStatus = collections.map((collection) => ({ + ...collection, + url: `/${collection.owner.username}/collections/${collection.id}`, + isAdded: collection.items.some((item) => item.projectId === projectId) + })); + + const getContent = () => { + if (showLoader) { + return ; + } else if (collections.length === 0) { + return t('AddToCollectionList.Empty'); } - return ( - - - - {this.getTitle()} - - {content} - - + ); - } -} - -const ProjectShape = PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string.isRequired - }).isRequired -}); - -const ItemsShape = PropTypes.shape({ - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - project: ProjectShape -}); + }; -CollectionList.propTypes = { - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - projectId: PropTypes.string.isRequired, - getCollections: PropTypes.func.isRequired, - getProject: PropTypes.func.isRequired, - addToCollection: PropTypes.func.isRequired, - removeFromCollection: 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, - items: PropTypes.arrayOf(ItemsShape) - }) - ).isRequired, - username: PropTypes.string, - loading: PropTypes.bool.isRequired, - project: PropTypes.shape({ - id: PropTypes.string, - owner: PropTypes.shape({ - id: PropTypes.string - }) - }), - t: PropTypes.func.isRequired + return ( + + + + {t('AddToCollectionList.Title')} + + {getContent()} + + + ); }; -CollectionList.defaultProps = { - project: { - id: undefined, - owner: undefined - }, - username: undefined +AddToCollectionList.propTypes = { + projectId: PropTypes.string.isRequired }; -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 - }; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectsActions, - ProjectActions, - ToastActions, - SortingActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(CollectionList) -); +export default AddToCollectionList; diff --git a/client/modules/IDE/components/AddToCollectionSketchList.jsx b/client/modules/IDE/components/AddToCollectionSketchList.jsx index eb70c2ed71..cc5b17379c 100644 --- a/client/modules/IDE/components/AddToCollectionSketchList.jsx +++ b/client/modules/IDE/components/AddToCollectionSketchList.jsx @@ -1,14 +1,10 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { withTranslation } from 'react-i18next'; -// import { find } from 'lodash'; -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 { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { addToCollection, removeFromCollection } from '../actions/collections'; +import { getProjects } from '../actions/projects'; import getSortedSketches from '../selectors/projects'; import Loader from '../../App/components/loader'; import QuickAddList from './QuickAddList'; @@ -17,149 +13,79 @@ import { QuickAddWrapper } from './AddToCollectionList'; -class SketchList extends React.Component { - constructor(props) { - super(props); - this.props.getProjects(this.props.username); +const AddToCollectionSketchList = ({ collection }) => { + const { t } = useTranslation(); - this.state = { - isInitialDataLoad: true - }; - } + const dispatch = useDispatch(); - componentWillReceiveProps(nextProps) { - if ( - this.props.sketches !== nextProps.sketches && - Array.isArray(nextProps.sketches) - ) { - this.setState({ - isInitialDataLoad: false - }); - } - } + const username = useSelector((state) => state.user.username); - getSketchesTitle() { - if (this.props.username === this.props.user.username) { - return this.props.t('AddToCollectionSketchList.Title'); - } - return this.props.t('AddToCollectionSketchList.AnothersTitle', { - anotheruser: this.props.username - }); - } + const sketches = useSelector(getSortedSketches); - handleCollectionAdd = (sketch) => { - this.props.addToCollection(this.props.collection.id, sketch.id); - }; - - handleCollectionRemove = (sketch) => { - this.props.removeFromCollection(this.props.collection.id, sketch.id); - }; + // TODO: improve loading state + const loading = useSelector((state) => state.loading); + const [hasLoadedData, setHasLoadedData] = useState(false); + const showLoader = loading && !hasLoadedData; - inCollection = (sketch) => - this.props.collection.items.find((item) => - item.isDeleted ? false : item.project.id === sketch.id - ) != null; + useEffect(() => { + dispatch(getProjects(username)).then(() => setHasLoadedData(true)); + }, [dispatch, username]); - render() { - const hasSketches = this.props.sketches.length > 0; - const sketchesWithAddedStatus = this.props.sketches.map((sketch) => ({ - ...sketch, - isAdded: this.inCollection(sketch), - url: `/${this.props.username}/sketches/${sketch.id}` - })); + const handleCollectionAdd = (sketch) => { + dispatch(addToCollection(collection.id, sketch.id)); + }; - let content = null; + const handleCollectionRemove = (sketch) => { + dispatch(removeFromCollection(collection.id, sketch.id)); + }; - if (this.props.loading && this.state.isInitialDataLoad) { - content = ; - } else if (hasSketches) { - content = ( - - ); - } else { - content = this.props.t('AddToCollectionSketchList.NoCollections'); + const sketchesWithAddedStatus = sketches.map((sketch) => ({ + ...sketch, + url: `/${username}/sketches/${sketch.id}`, + isAdded: collection.items.some( + (item) => item.projectId === sketch.id && !item.isDeleted + ) + })); + + const getContent = () => { + if (showLoader) { + return ; + } else if (sketches.length === 0) { + // TODO: shouldn't it be NoSketches? -Linda + return t('AddToCollectionSketchList.NoCollections'); } - return ( - - - - {this.getSketchesTitle()} - - {content} - - + ); - } -} + }; -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, + return ( + + + + {t('AddToCollectionSketchList.Title')} + + {getContent()} + + + ); +}; + +AddToCollectionSketchList.propTypes = { collection: PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, items: PropTypes.arrayOf( PropTypes.shape({ - project: PropTypes.shape({ - id: PropTypes.string.isRequired - }) + projectId: PropTypes.string.isRequired, + isDeleted: PropTypes.bool }) ) - }).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 -}; - -SketchList.defaultProps = { - username: undefined + }).isRequired }; -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 AddToCollectionSketchList; diff --git a/client/modules/IDE/components/AssetList.jsx b/client/modules/IDE/components/AssetList.jsx index 5b3c600688..720f21734b 100644 --- a/client/modules/IDE/components/AssetList.jsx +++ b/client/modules/IDE/components/AssetList.jsx @@ -1,130 +1,68 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { bindActionCreators } from 'redux'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet'; import prettyBytes from 'pretty-bytes'; -import { withTranslation } from 'react-i18next'; +import { useTranslation, withTranslation } from 'react-i18next'; +import MenuItem from '../../../components/Dropdown/MenuItem'; +import TableDropdown from '../../../components/Dropdown/TableDropdown'; import Loader from '../../App/components/loader'; +import { deleteAssetRequest } from '../actions/assets'; import * as AssetActions from '../actions/assets'; -import DownFilledTriangleIcon from '../../../images/down-filled-triangle.svg'; -class AssetListRowBase extends React.Component { - constructor(props) { - super(props); - this.state = { - isFocused: false, - optionsOpen: false - }; - } - - onFocusComponent = () => { - this.setState({ isFocused: true }); - }; - - onBlurComponent = () => { - this.setState({ isFocused: false }); - setTimeout(() => { - if (!this.state.isFocused) { - this.closeOptions(); - } - }, 200); - }; - - openOptions = () => { - this.setState({ - optionsOpen: true - }); - }; +const AssetMenu = ({ item: asset }) => { + const { t } = useTranslation(); - closeOptions = () => { - this.setState({ - optionsOpen: false - }); - }; + const dispatch = useDispatch(); - toggleOptions = () => { - if (this.state.optionsOpen) { - this.closeOptions(); - } else { - this.openOptions(); + const handleAssetDelete = () => { + const { key, name } = asset; + if (window.confirm(t('Common.DeleteConfirmation', { name }))) { + dispatch(deleteAssetRequest(key)); } }; - handleDropdownOpen = () => { - this.closeOptions(); - this.openOptions(); - }; + return ( + + {t('AssetList.Delete')} + + {t('AssetList.OpenNewTab')} + + + ); +}; - handleAssetDelete = () => { - const { key, name } = this.props.asset; - this.closeOptions(); - if (window.confirm(this.props.t('Common.DeleteConfirmation', { name }))) { - this.props.deleteAssetRequest(key); - } - }; +AssetMenu.propTypes = { + item: PropTypes.shape({ + key: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired +}; - render() { - const { asset, username, t } = this.props; - const { optionsOpen } = this.state; - return ( - - - - {asset.name} - - - {prettyBytes(asset.size)} - - {asset.sketchId && ( - - {asset.sketchName} - - )} - - - - {optionsOpen && ( -

    - )} - - - ); - } -} +const AssetListRowBase = ({ asset, username }) => ( + + + + {asset.name} + + + {prettyBytes(asset.size)} + + {asset.sketchId && ( + + {asset.sketchName} + + )} + + + + + +); AssetListRowBase.propTypes = { asset: PropTypes.shape({ @@ -135,9 +73,7 @@ AssetListRowBase.propTypes = { name: PropTypes.string.isRequired, size: PropTypes.number.isRequired }).isRequired, - deleteAssetRequest: PropTypes.func.isRequired, - username: PropTypes.string.isRequired, - t: PropTypes.func.isRequired + username: PropTypes.string.isRequired }; function mapStateToPropsAssetListRow(state) { diff --git a/client/modules/IDE/components/CollectionList/CollectionList.jsx b/client/modules/IDE/components/CollectionList/CollectionList.jsx index e3c910881e..9d641f5e66 100644 --- a/client/modules/IDE/components/CollectionList/CollectionList.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionList.jsx @@ -227,7 +227,6 @@ class CollectionList extends React.Component { isFixedHeight > - dates.format(date, { showTime: !mobile }); - -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', { - name: this.props.collection.name - }) - ) - ) { - this.props.deleteCollection(this.props.collection.id); - } - }; - - handleRenameOpen = () => { - this.closeAll(); - this.setState( - { - renameOpen: true, - renameValue: this.props.collection.name - }, - () => this.renameInput.current.focus() - ); - }; - - handleRenameChange = (e) => { - this.setState({ - renameValue: e.target.value - }); - }; - - handleRenameEnter = (e) => { - if (e.key === 'Enter') { - this.updateName(); - this.closeAll(); - } - }; - - handleRenameBlur = () => { - this.updateName(); - this.closeAll(); - }; - - updateName = () => { - const isValid = this.state.renameValue.trim().length !== 0; - if (isValid) { - this.props.editCollection(this.props.collection.id, { - name: this.state.renameValue.trim() - }); - } - }; - - renderActions = () => { - const { optionsOpen } = this.state; - const userIsOwner = this.props.user.username === this.props.username; - - return ( - - - {optionsOpen && ( -
      -
    • - -
    • - {userIsOwner && ( -
    • - -
    • - )} - {userIsOwner && ( -
    • - -
    • - )} -
    - )} -
    - ); - }; - - renderCollectionName = () => { - const { collection, username } = this.props; - const { renameOpen, renameValue } = this.state; - - return ( - - - {renameOpen ? '' : collection.name} - - {renameOpen && ( - e.stopPropagation()} - ref={this.renameInput} - /> - )} - - ); - }; - - render() { - const { collection, mobile } = this.props; - - return ( - - - - {this.renderCollectionName()} - - - {formatDateCell(collection.createdAt, mobile)} - {formatDateCell(collection.updatedAt, mobile)} - - {mobile && 'sketches: '} - {(collection.items || []).length} - - {this.renderActions()} - - ); - } -} - -CollectionListRowBase.propTypes = { - collection: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - owner: PropTypes.shape({ - username: PropTypes.string.isRequired - }).isRequired, - createdAt: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - items: PropTypes.arrayOf( - PropTypes.shape({ - project: PropTypes.shape({ - id: PropTypes.string.isRequired - }) - }) - ) - }).isRequired, - username: PropTypes.string.isRequired, - user: PropTypes.shape({ - username: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - deleteCollection: PropTypes.func.isRequired, - editCollection: PropTypes.func.isRequired, - onAddSketches: PropTypes.func.isRequired, - mobile: PropTypes.bool, - t: PropTypes.func.isRequired -}; - -CollectionListRowBase.defaultProps = { - mobile: false -}; - -function mapDispatchToPropsSketchListRow(dispatch) { - return bindActionCreators( - Object.assign( - {}, - CollectionsActions, - ProjectActions, - IdeActions, - ToastActions - ), - dispatch - ); -} - -export default withTranslation()( - connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase) -); +import PropTypes from 'prop-types'; +import React, { useState, useRef } from 'react'; +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'; + +const formatDateCell = (date, mobile = false) => + dates.format(date, { showTime: !mobile }); + +const CollectionListRowBase = (props) => { + const [renameOpen, setRenameOpen] = useState(false); + const [renameValue, setRenameValue] = useState(''); + const renameInput = useRef(null); + + const closeAll = () => { + setRenameOpen(false); + }; + + const updateName = () => { + const isValid = renameValue.trim().length !== 0; + if (isValid) { + props.editCollection(props.collection.id, { + name: renameValue.trim() + }); + } + }; + + const handleAddSketches = () => { + closeAll(); + props.onAddSketches(); + }; + + const handleCollectionDelete = () => { + closeAll(); + if ( + window.confirm( + props.t('Common.DeleteConfirmation', { + name: props.collection.name + }) + ) + ) { + props.deleteCollection(props.collection.id); + } + }; + + const handleRenameOpen = () => { + closeAll(); + setRenameOpen(true); + setRenameValue(props.collection.name); + if (renameInput.current) { + renameInput.current.focus(); + } + }; + + const handleRenameChange = (e) => { + setRenameValue(e.target.value); + }; + + const handleRenameEnter = (e) => { + if (e.key === 'Enter') { + updateName(); + closeAll(); + } + }; + + const handleRenameBlur = () => { + updateName(); + closeAll(); + }; + + const renderActions = () => { + const userIsOwner = props.user.username === props.username; + + return ( + + + {props.t('CollectionListRow.AddSketch')} + + + {props.t('CollectionListRow.Delete')} + + + {props.t('CollectionListRow.Rename')} + + + ); + }; + + const renderCollectionName = () => { + const { collection, username } = props; + + return ( + <> + + {renameOpen ? '' : collection.name} + + {renameOpen && ( + e.stopPropagation()} + ref={renameInput} + /> + )} + + ); + }; + + const { collection, mobile } = props; + + return ( + + + {renderCollectionName()} + + {formatDateCell(collection.createdAt, mobile)} + {formatDateCell(collection.updatedAt, mobile)} + + {mobile && 'sketches: '} + {(collection.items || []).length} + + {renderActions()} + + ); +}; + +CollectionListRowBase.propTypes = { + collection: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + owner: PropTypes.shape({ + username: PropTypes.string.isRequired + }).isRequired, + createdAt: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + items: PropTypes.arrayOf( + PropTypes.shape({ + project: PropTypes.shape({ + id: PropTypes.string.isRequired + }) + }) + ) + }).isRequired, + username: PropTypes.string.isRequired, + user: PropTypes.shape({ + username: PropTypes.string, + authenticated: PropTypes.bool.isRequired + }).isRequired, + deleteCollection: PropTypes.func.isRequired, + editCollection: PropTypes.func.isRequired, + onAddSketches: PropTypes.func.isRequired, + mobile: PropTypes.bool, + t: PropTypes.func.isRequired +}; + +CollectionListRowBase.defaultProps = { + mobile: false +}; + +function mapDispatchToPropsSketchListRow(dispatch) { + return bindActionCreators( + Object.assign( + {}, + CollectionsActions, + ProjectActions, + IdeActions, + ToastActions + ), + dispatch + ); +} + +export default withTranslation()( + connect(null, mapDispatchToPropsSketchListRow)(CollectionListRowBase) +); diff --git a/client/modules/IDE/components/CopyableInput.jsx b/client/modules/IDE/components/CopyableInput.jsx index 1fa9f6bec3..2d7b5036f2 100644 --- a/client/modules/IDE/components/CopyableInput.jsx +++ b/client/modules/IDE/components/CopyableInput.jsx @@ -1,95 +1,91 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Clipboard from 'clipboard'; import classNames from 'classnames'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import ShareIcon from '../../../images/share.svg'; -class CopyableInput extends React.Component { - constructor(props) { - super(props); - this.onMouseLeaveHandler = this.onMouseLeaveHandler.bind(this); - } +const CopyableInput = ({ label, value, hasPreviewLink }) => { + const { t } = useTranslation(); - componentDidMount() { - this.clipboard = new Clipboard(this.input, { - target: () => this.input - }); + const [isCopied, setIsCopied] = useState(false); - this.clipboard.on('success', (e) => { - this.tooltip.classList.add('tooltipped'); - this.tooltip.classList.add('tooltipped-n'); - }); - } + const inputRef = useRef(null); - componentWillUnmount() { - this.clipboard.destroy(); - } + useEffect(() => { + const input = inputRef.current; - onMouseLeaveHandler() { - this.tooltip.classList.remove('tooltipped'); - this.tooltip.classList.remove('tooltipped-n'); - } + if (!input) return; // should never happen - render() { - const { label, value, hasPreviewLink } = this.props; - const copyableInputClass = classNames({ - 'copyable-input': true, - 'copyable-input--with-preview': hasPreviewLink + const clipboard = new Clipboard(input, { + target: () => input }); - return ( -
    -
    { - this.tooltip = element; - }} - onMouseLeave={this.onMouseLeaveHandler} - > - -
    - {hasPreviewLink && ( - - + + clipboard.on('success', () => { + setIsCopied(true); + }); + + // eslint-disable-next-line consistent-return + return () => { + clipboard.destroy(); + }; + }, [inputRef, setIsCopied]); + + return ( +
    +
    setIsCopied(false)} + > +
    - ); - } -} + {hasPreviewLink && ( + + + )} +
    + ); +}; CopyableInput.propTypes = { label: PropTypes.string.isRequired, value: PropTypes.string.isRequired, - hasPreviewLink: PropTypes.bool, - t: PropTypes.func.isRequired + hasPreviewLink: PropTypes.bool }; CopyableInput.defaultProps = { hasPreviewLink: false }; -export default withTranslation()(CopyableInput); +export default CopyableInput; diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 8c1781ee97..cd060d3bb5 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -23,6 +23,7 @@ import 'codemirror/addon/fold/comment-fold'; import 'codemirror/addon/fold/foldcode'; import 'codemirror/addon/fold/foldgutter'; import 'codemirror/addon/fold/indent-fold'; +import 'codemirror/addon/fold/xml-fold'; import 'codemirror/addon/comment/comment'; import 'codemirror/keymap/sublime'; import 'codemirror/addon/search/searchcursor'; diff --git a/client/modules/IDE/components/FloatingActionButton.jsx b/client/modules/IDE/components/FloatingActionButton.jsx index e4ce3aaca7..52d2827523 100644 --- a/client/modules/IDE/components/FloatingActionButton.jsx +++ b/client/modules/IDE/components/FloatingActionButton.jsx @@ -38,17 +38,20 @@ const Button = styled.button` } `; -const FloatingActionButton = (props) => { +const FloatingActionButton = ({ syncFileContent, offsetBottom }) => { const isPlaying = useSelector((state) => state.ide.isPlaying); const dispatch = useDispatch(); return ( - {optionsOpen && ( -
      - {userIsOwner && ( -
    • - -
    • - )} -
    • - -
    • - {this.props.user.authenticated && ( -
    • - -
    • - )} - {this.props.user.authenticated && ( -
    • - -
    • - )} - {/*
    • - -
    • */} - {userIsOwner && ( -
    • - -
    • - )} -
    - )} + + + {this.props.t('SketchList.DropdownRename')} + + + {this.props.t('SketchList.DropdownDownload')} + + + {this.props.t('SketchList.DropdownDuplicate')} + + { + this.props.onAddToCollection(); + }} + > + {this.props.t('SketchList.DropdownAddToCollection')} + + + {/* + + Share + + */} + + {this.props.t('SketchList.DropdownDelete')} + + ); }; @@ -544,9 +418,7 @@ class SketchList extends React.Component { } > )} diff --git a/client/modules/IDE/components/Toast.jsx b/client/modules/IDE/components/Toast.jsx index 0f16282220..882d101748 100644 --- a/client/modules/IDE/components/Toast.jsx +++ b/client/modules/IDE/components/Toast.jsx @@ -13,7 +13,7 @@ export default function Toast() { return null; } return ( -
    +

    {t(text)}