Merge pull request #1539 from ghalestrilo/feature/mobile-files-tab

Implement Mobile version of Files tab / sidebar
This commit is contained in:
ghalestrilo 2020-08-17 18:06:39 -03:00 committed by GitHub
commit fdf60aaa98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 274 additions and 38 deletions

View file

@ -16,6 +16,12 @@ import More from '../images/more.svg';
import Code from '../images/code.svg'; import Code from '../images/code.svg';
import Terminal from '../images/terminal.svg'; import Terminal from '../images/terminal.svg';
import Folder from '../images/folder-padded.svg';
import CircleTerminal from '../images/circle-terminal.svg';
import CircleFolder from '../images/circle-folder.svg';
import CircleInfo from '../images/circle-info.svg';
// HOC that adds the right web accessibility props // HOC that adds the right web accessibility props
// https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html // https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html
@ -81,3 +87,9 @@ export const PlayIcon = withLabel(Play);
export const MoreIcon = withLabel(More); export const MoreIcon = withLabel(More);
export const TerminalIcon = withLabel(Terminal); export const TerminalIcon = withLabel(Terminal);
export const CodeIcon = withLabel(Code); export const CodeIcon = withLabel(Code);
export const FolderIcon = withLabel(Folder);
export const CircleTerminalIcon = withLabel(CircleTerminal);
export const CircleFolderIcon = withLabel(CircleFolder);
export const CircleInfoIcon = withLabel(CircleInfo);

View file

@ -25,7 +25,7 @@ const DropdownWrapper = styled.ul`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: auto; height: auto;
z-index: 9999; z-index: 2;
border-radius: ${remSize(6)}; border-radius: ${remSize(6)};
& li:first-child { border-radius: ${remSize(5)} ${remSize(5)} 0 0; } & li:first-child { border-radius: ${remSize(5)} ${remSize(5)} 0 0; }

View file

@ -1,30 +1,51 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { remSize } from '../../theme'; import { remSize, prop } from '../../theme';
import IconButton from './IconButton'; import IconButton from './IconButton';
import { TerminalIcon } from '../../common/icons'; import { TerminalIcon, FolderIcon } from '../../common/icons';
import * as IDEActions from '../../modules/IDE/actions/ide'; import * as IDEActions from '../../modules/IDE/actions/ide';
const BottomBarContent = styled.h2` const BottomBarContent = styled.div`
padding: ${remSize(8)}; padding: ${remSize(8)};
display: flex;
svg { svg {
max-height: ${remSize(32)}; max-height: ${remSize(32)};
}
path { fill: ${prop('primaryTextColor')} !important }
.inverted {
path { fill: ${prop('backgroundColor')} !important }
rect { fill: ${prop('primaryTextColor')} !important }
} }
`; `;
export default () => { // Maybe this component shouldn't be connected, and instead just receive the `actions` prop
const ActionStrip = ({ toggleExplorer }) => {
const { expandConsole, collapseConsole } = bindActionCreators(IDEActions, useDispatch()); const { expandConsole, collapseConsole } = bindActionCreators(IDEActions, useDispatch());
const { consoleIsExpanded } = useSelector(state => state.ide); const { consoleIsExpanded } = useSelector(state => state.ide);
const actions = [{ icon: TerminalIcon, aria: 'Say Something', action: consoleIsExpanded ? collapseConsole : expandConsole }]; const actions = [
{
icon: TerminalIcon, inverted: true, aria: 'Open terminal console', action: consoleIsExpanded ? collapseConsole : expandConsole
},
{ icon: FolderIcon, aria: 'Open files explorer', action: toggleExplorer }
];
return ( return (
<BottomBarContent> <BottomBarContent>
{actions.map(({ icon, aria, action }) => {actions.map(({
(<IconButton icon, aria, action, inverted
}) =>
(
<IconButton
inverted={inverted}
className={inverted && 'inverted'}
icon={icon} icon={icon}
aria-label={aria} aria-label={aria}
key={`bottom-bar-${aria}`} key={`bottom-bar-${aria}`}
@ -33,3 +54,13 @@ export default () => {
</BottomBarContent> </BottomBarContent>
); );
}; };
ActionStrip.propTypes = {
toggleExplorer: PropTypes.func
};
ActionStrip.defaultProps = {
toggleExplorer: () => {}
};
export default ActionStrip;

View file

@ -0,0 +1,24 @@
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import Sidebar from './Sidebar';
import ConnectedFileNode from '../../modules/IDE/components/FileNode';
const Explorer = ({ id, canEdit, onPressClose }) => (
<Sidebar title="Files" onPressClose={onPressClose}>
<ConnectedFileNode id={id} canEdit={canEdit} onClickFile={() => onPressClose()} />
</Sidebar>
);
Explorer.propTypes = {
id: PropTypes.number.isRequired,
onPressClose: PropTypes.func,
canEdit: PropTypes.bool
};
Explorer.defaultProps = {
canEdit: false,
onPressClose: () => {}
};
export default Explorer;

View file

@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { remSize, prop } from '../../theme';
import Button from '../../common/Button';
import IconButton from './IconButton';
const FloatingContainer = styled.div`
position: fixed;
right: ${remSize(16)};
top: ${remSize(80)};
text-align: right;
z-index: 3;
svg { width: ${remSize(32)}; };
svg > path { fill: ${prop('Button.default.background')} !important };
`;
const FloatingNav = ({ items }) => (
<FloatingContainer>
{ items.map(({ icon, onPress }) =>
(
<IconButton
onClick={onPress}
icon={icon}
/>
))}
</FloatingContainer>
);
FloatingNav.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.element,
onPress: PropTypes.func
}))
};
FloatingNav.defaultProps = {
items: []
};
export default FloatingNav;

View file

@ -14,7 +14,7 @@ const textColor = ({ transparent, inverted }) => prop((transparent === false &&
const HeaderDiv = styled.div` const HeaderDiv = styled.div`
position: fixed; ${props => props.fixed && 'position: fixed;'}
width: 100%; width: 100%;
background: ${props => background(props)}; background: ${props => background(props)};
color: ${textColor}; color: ${textColor};
@ -57,9 +57,9 @@ const TitleContainer = styled.div`
const Header = ({ const Header = ({
title, subtitle, leftButton, children, title, subtitle, leftButton, children,
transparent, inverted, slim transparent, inverted, slim, fixed
}) => ( }) => (
<HeaderDiv transparent={transparent} slim={slim} inverted={inverted}> <HeaderDiv transparent={transparent} slim={slim} inverted={inverted} fixed={fixed}>
{leftButton} {leftButton}
<TitleContainer padded={subtitle === null}> <TitleContainer padded={subtitle === null}>
{title && <h2>{title}</h2>} {title && <h2>{title}</h2>}
@ -79,6 +79,7 @@ Header.propTypes = {
transparent: PropTypes.bool, transparent: PropTypes.bool,
inverted: PropTypes.bool, inverted: PropTypes.bool,
slim: PropTypes.bool, slim: PropTypes.bool,
fixed: PropTypes.bool,
}; };
Header.defaultProps = { Header.defaultProps = {
@ -88,7 +89,8 @@ Header.defaultProps = {
children: [], children: [],
transparent: false, transparent: false,
inverted: false, inverted: false,
slim: false slim: false,
fixed: true
}; };
export default Header; export default Header;

View file

@ -2,7 +2,10 @@ import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { remSize } from '../../theme'; import { remSize } from '../../theme';
// Applies padding to top and bottom so editor content is always visible
export default styled.div` export default styled.div`
z-index: 0; z-index: 0;
margin-top: ${remSize(16)}; margin-top: ${remSize(16)};
.CodeMirror-sizer > * { padding-bottom: ${remSize(320)}; };
`; `;

View file

@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router';
import styled from 'styled-components';
import { remSize, prop, common } from '../../theme';
import Header from './Header';
import IconButton from './IconButton';
import { ExitIcon } from '../../common/icons';
const SidebarWrapper = styled.div`
height: 100%;
width: ${remSize(180)};
position: fixed;
z-index: 2;
left: 0;
background: white;
box-shadow: 0 6px 6px 0 rgba(0,0,0,0.10);
`;
const Sidebar = ({ title, onPressClose, children }) => (
<SidebarWrapper>
{title &&
<Header slim title={title} fixed={false}>
<IconButton onClick={onPressClose} icon={ExitIcon} aria-label="Return to ide view" />
</Header>}
{children}
</SidebarWrapper>
);
Sidebar.propTypes = {
title: PropTypes.string,
onPressClose: PropTypes.func,
children: PropTypes.oneOfType([PropTypes.element, PropTypes.arrayOf(PropTypes.element)]),
};
Sidebar.defaultProps = {
title: null,
children: [],
onPressClose: () => {}
};
export default Sidebar;

View file

@ -1,10 +1,29 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components';
import { useModalBehavior } from '../utils/custom-hooks'; import { useModalBehavior } from '../utils/custom-hooks';
export default (component) => { const BackgroundOverlay = styled.div`
const [visible, trigger, setRef] = useModalBehavior(); position: fixed;
z-index: 2;
width: 100% !important;
height: 100% !important;
const wrapper = () => <div ref={setRef}> {visible && component} </div>; // eslint-disable-line background: black;
opacity: 0.3;
`;
return [trigger, wrapper]; export default (Element, hasOverlay = false) => {
const [visible, toggle, setRef] = useModalBehavior();
const wrapper = () => (visible &&
<div>
{hasOverlay && <BackgroundOverlay />}
<div ref={setRef}>
{ (typeof (Element) === 'function')
? Element(toggle)
: Element}
</div>
</div>);
return [toggle, wrapper];
}; };

View file

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#333333"/>
<path d="M25.144 12.0961V22.4321C25.144 22.8801 24.792 23.2321 24.344 23.2321H7.768C7.32 23.2321 7 22.8801 7 22.4321V12.0961C7 11.2321 7.704 10.5281 8.568 10.5281H23.576C24.44 10.5281 25.144 11.2321 25.144 12.0961Z" fill="#F0F0F0"/>
<path d="M9.24023 9.6C9.24023 9.6 9.24023 8 10.5842 8H15.1282C16.4402 8 16.4402 9.6 16.4402 9.6H9.24023Z" fill="#F0F0F0"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#333333"/>
<path d="M16 7C11.0154 7 7 11.0154 7 16C7 20.95 11.0154 25 16 25C20.95 25 25 20.95 25 16C25 11.0154 20.95 7 16 7ZM17.3846 21.5038H14.6846V13.7154H17.3846V21.5038ZM16 12.9538C15.1 12.9538 14.4077 12.2615 14.4077 11.3615C14.4077 10.4962 15.1 9.80385 16 9.80385C16.9 9.80385 17.5923 10.4962 17.5923 11.3615C17.5923 12.2615 16.9 12.9538 16 12.9538Z" fill="#F0F0F0"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

View file

@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#333333"/>
<rect x="5" y="8" width="22" height="16" rx="2" fill="#F0F0F0"/>
<path d="M24 21H14V20H24V21Z" fill="#333333"/>
<path d="M10.4081 16.0231L8.3676 18.0637C8.27757 18.1537 8.15754 18.1537 8.06752 18.0637C7.97749 17.9736 7.97749 17.8536 8.06752 17.7636L9.95802 15.8731L8.06752 13.9826C7.97749 13.8926 7.97749 13.7725 8.06752 13.6675C8.15754 13.5775 8.27757 13.5775 8.3676 13.6675L10.4081 15.723C10.4532 15.753 10.4832 15.8131 10.4832 15.8731C10.4832 15.9181 10.4532 15.9781 10.4081 16.0231Z" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 656 B

View file

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.144 12.0961V22.4321C25.144 22.8801 24.792 23.2321 24.344 23.2321H7.768C7.32 23.2321 7 22.8801 7 22.4321V12.0961C7 11.2321 7.704 10.5281 8.568 10.5281H23.576C24.44 10.5281 25.144 11.2321 25.144 12.0961Z" fill="#F0F0F0"/>
<path d="M9.24023 9.6C9.24023 9.6 9.24023 8 10.5842 8H15.1282C16.4402 8 16.4402 9.6 16.4402 9.6H9.24023Z" fill="#F0F0F0"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

View file

@ -19,7 +19,9 @@ class App extends React.Component {
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
const locationWillChange = nextProps.location !== this.props.location; const locationWillChange = nextProps.location !== this.props.location;
const shouldSkipRemembering = nextProps.location.state && nextProps.location.state.skipSavingPath === true; const shouldSkipRemembering =
nextProps.location.state &&
nextProps.location.state.skipSavingPath === true;
if (locationWillChange && !shouldSkipRemembering) { if (locationWillChange && !shouldSkipRemembering) {
this.props.setPreviousPath(this.props.location.pathname); this.props.setPreviousPath(this.props.location.pathname);

View file

@ -108,10 +108,15 @@ export class FileNode extends React.Component {
handleFileClick = (event) => { handleFileClick = (event) => {
event.stopPropagation(); event.stopPropagation();
const { isDeleting } = this.state; const { isDeleting } = this.state;
const { id, setSelectedFile, name } = this.props; const {
id, setSelectedFile, name, onClickFile
} = this.props;
if (name !== 'root' && !isDeleting) { if (name !== 'root' && !isDeleting) {
setSelectedFile(id); setSelectedFile(id);
} }
// debugger; // eslint-disable-line
if (onClickFile) { onClickFile(); }
} }
handleFileNameChange = (event) => { handleFileNameChange = (event) => {
@ -214,7 +219,7 @@ export class FileNode extends React.Component {
renderChild = childId => ( renderChild = childId => (
<li key={childId}> <li key={childId}>
<ConnectedFileNode id={childId} parentId={this.props.id} canEdit={this.props.canEdit} /> <ConnectedFileNode id={childId} parentId={this.props.id} canEdit={this.props.canEdit} onClickFile={this.props.onClickFile} />
</li> </li>
) )
@ -382,10 +387,12 @@ FileNode.propTypes = {
hideFolderChildren: PropTypes.func.isRequired, hideFolderChildren: PropTypes.func.isRequired,
canEdit: PropTypes.bool.isRequired, canEdit: PropTypes.bool.isRequired,
openUploadFileModal: PropTypes.func.isRequired, openUploadFileModal: PropTypes.func.isRequired,
authenticated: PropTypes.bool.isRequired authenticated: PropTypes.bool.isRequired,
onClickFile: PropTypes.func
}; };
FileNode.defaultProps = { FileNode.defaultProps = {
onClickFile: null,
parentId: '0', parentId: '0',
isSelectedFile: false, isSelectedFile: false,
isFolderClosed: false, isFolderClosed: false,

View file

@ -20,24 +20,41 @@ import { getHTMLFile } from '../reducers/files';
// Local Imports // Local Imports
import Editor from '../components/Editor'; import Editor from '../components/Editor';
import { PlayIcon, MoreIcon } from '../../../common/icons'; import { PlayIcon, MoreIcon, CircleFolderIcon } from '../../../common/icons';
import IconButton from '../../../components/mobile/IconButton'; import IconButton from '../../../components/mobile/IconButton';
import Header from '../../../components/mobile/Header'; import Header from '../../../components/mobile/Header';
import Screen from '../../../components/mobile/MobileScreen'; import Screen from '../../../components/mobile/MobileScreen';
import Footer from '../../../components/mobile/Footer'; import Footer from '../../../components/mobile/Footer';
import IDEWrapper from '../../../components/mobile/IDEWrapper'; import IDEWrapper from '../../../components/mobile/IDEWrapper';
import MobileExplorer from '../../../components/mobile/Explorer';
import Console from '../components/Console'; import Console from '../components/Console';
import { remSize } from '../../../theme'; import { remSize } from '../../../theme';
// import OverlayManager from '../../../components/OverlayManager';
import ActionStrip from '../../../components/mobile/ActionStrip'; import ActionStrip from '../../../components/mobile/ActionStrip';
import useAsModal from '../../../components/useAsModal'; import useAsModal from '../../../components/useAsModal';
import { PreferencesIcon } from '../../../common/icons'; import { PreferencesIcon } from '../../../common/icons';
import Dropdown from '../../../components/Dropdown'; import Dropdown from '../../../components/Dropdown';
const getRootFile = files => files && files.filter(file => file.name === 'root')[0];
const getRootFileID = files => (root => root && root.id)(getRootFile(files));
const isUserOwner = ({ project, user }) => const isUserOwner = ({ project, user }) =>
project.owner && project.owner.id === user.id; project.owner && project.owner.id === user.id;
// const userCanEditProject = (props) => {
// let canEdit;
// if (!props.owner) {
// canEdit = true;
// } else if (props.user.authenticated && props.owner.id === props.user.id) {
// canEdit = true;
// } else {
// canEdit = false;
// }
// return canEdit;
// };
const Expander = styled.div` const Expander = styled.div`
height: ${props => (props.expanded ? remSize(160) : remSize(27))}; height: ${props => (props.expanded ? remSize(160) : remSize(27))};
`; `;
@ -46,7 +63,7 @@ const NavItem = styled.li`
position: relative; position: relative;
`; `;
const getNatOptions = (username = undefined) => const getNavOptions = (username = undefined) =>
(username (username
? [ ? [
{ icon: PreferencesIcon, title: 'Preferences', href: '/mobile/preferences', }, { icon: PreferencesIcon, title: 'Preferences', href: '/mobile/preferences', },
@ -67,17 +84,13 @@ const MobileIDEView = (props) => {
selectedFile, updateFileContent, files, user, params, selectedFile, updateFileContent, files, user, params,
closeEditorOptions, showEditorOptions, closeEditorOptions, showEditorOptions,
startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console, startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console,
showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch, getProject, clearPersistedState showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch, getProject, clearPersistedState, setUnsavedChanges
} = props; } = props;
const [tmController, setTmController] = useState(null); // eslint-disable-line const [tmController, setTmController] = useState(null); // eslint-disable-line
const { username } = user; const { username } = user;
const [triggerNavDropdown, NavDropDown] = useAsModal(<Dropdown
items={getNatOptions(username)}
align="right"
/>);
// Force state reset // Force state reset
useEffect(clearPersistedState, []); useEffect(clearPersistedState, []);
@ -95,16 +108,29 @@ const MobileIDEView = (props) => {
setCurrentProjectID(params.project_id); setCurrentProjectID(params.project_id);
}, [params, project, username]); }, [params, project, username]);
// Screen Modals
const [toggleNavDropdown, NavDropDown] = useAsModal(<Dropdown
items={getNavOptions(username)}
align="right"
/>);
const [toggleExplorer, Explorer] = useAsModal(toggle =>
(<MobileExplorer
id={getRootFileID(files)}
canEdit={false}
onPressClose={toggle}
/>), true);
return ( return (
<Screen fullscreen> <Screen fullscreen>
<Explorer />
<Header <Header
title={project.name} title={project.name}
subtitle={selectedFile.name} subtitle={selectedFile.name}
> >
<NavItem> <NavItem>
<IconButton <IconButton
onClick={triggerNavDropdown} onClick={toggleNavDropdown}
icon={MoreIcon} icon={MoreIcon}
aria-label="Options" aria-label="Options"
/> />
@ -147,6 +173,7 @@ const MobileIDEView = (props) => {
hideRuntimeErrorWarning={hideRuntimeErrorWarning} hideRuntimeErrorWarning={hideRuntimeErrorWarning}
runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible} runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible}
provideController={setTmController} provideController={setTmController}
setUnsavedChanges={setUnsavedChanges}
/> />
</IDEWrapper> </IDEWrapper>
@ -156,7 +183,7 @@ const MobileIDEView = (props) => {
<Console /> <Console />
</Expander> </Expander>
)} )}
<ActionStrip /> <ActionStrip toggleExplorer={toggleExplorer} />
</Footer> </Footer>
</Screen> </Screen>
); );
@ -267,6 +294,7 @@ MobileIDEView.propTypes = {
username: PropTypes.string, username: PropTypes.string,
}).isRequired, }).isRequired,
setUnsavedChanges: PropTypes.func.isRequired,
getProject: PropTypes.func.isRequired, getProject: PropTypes.func.isRequired,
clearPersistedState: PropTypes.func.isRequired, clearPersistedState: PropTypes.func.isRequired,
params: PropTypes.shape({ params: PropTypes.shape({

View file

@ -28,7 +28,7 @@ export const useModalBehavior = (hideOverlay) => {
const handleClickOutside = ({ target }) => { const handleClickOutside = ({ target }) => {
if (ref && ref.current && !ref.current.contains(target)) { if (ref && ref.current && !(ref.current.contains && ref.current.contains(target))) {
hide(); hide();
} }
}; };