From f891790b504fa933c46888c21e06e1508bfd5104 Mon Sep 17 00:00:00 2001 From: Chris Whong Date: Tue, 27 Aug 2019 16:38:05 -0400 Subject: [PATCH] feat: add router-based onboarding flow, user menu --- app/actions/api.ts | 6 +- app/actions/session.ts | 23 +++++- app/actions/ui.ts | 24 +++++- app/components/App.tsx | 101 +++++++++--------------- app/components/ComponentList.tsx | 26 +++++-- app/components/Dataset.tsx | 56 ++++++++++++- app/components/DatasetComponent.tsx | 117 +++++++++++++++------------- app/components/DatasetList.tsx | 4 +- app/components/Onboard.tsx | 90 --------------------- app/components/Signin.tsx | 26 +++---- app/components/Signup.tsx | 35 +++++---- app/components/WelcomeTemplate.tsx | 4 +- app/containers/AppContainer.tsx | 15 ++-- app/containers/DatasetContainer.tsx | 11 ++- app/containers/RoutesContainer.tsx | 26 ++++++- app/index.tsx | 3 +- app/models/store.ts | 6 +- app/reducers/index.ts | 10 ++- app/reducers/selections.ts | 42 +++++----- app/reducers/ui.ts | 28 +++++-- app/routes.tsx | 66 ++++++++++++++-- app/scss/_dataset.scss | 99 ++++++++++++++++------- app/scss/_welcome.scss | 6 ++ app/scss/style.scss | 1 - app/store/api.ts | 25 +----- app/utils/localstore.ts | 2 +- package.json | 8 +- yarn.lock | 48 +++++++++++- 28 files changed, 531 insertions(+), 377 deletions(-) delete mode 100644 app/components/Onboard.tsx diff --git a/app/actions/api.ts b/app/actions/api.ts index b695ad78..d25ce146 100644 --- a/app/actions/api.ts +++ b/app/actions/api.ts @@ -90,7 +90,7 @@ export function fetchWorkingDataset (): ApiActionThunk { const action = { type: 'dataset', [CALL_API]: { - endpoint: 'dataset', + endpoint: '', method: 'GET', params: { fsi: isLinked }, segments: { @@ -128,7 +128,7 @@ export function fetchCommitDataset (): ApiActionThunk { const response = await dispatch({ type: 'commitdataset', [CALL_API]: { - endpoint: 'dataset', + endpoint: '', method: 'GET', segments: { peername: selections.peername, @@ -386,7 +386,7 @@ export function initDataset (filepath: string, name: string, format: string): Ap const action = { type: 'init', [CALL_API]: { - endpoint: 'init', + endpoint: 'init/', method: 'POST', params: { filepath, diff --git a/app/actions/session.ts b/app/actions/session.ts index 38a84e2f..c91bbbb2 100644 --- a/app/actions/session.ts +++ b/app/actions/session.ts @@ -6,7 +6,7 @@ export function fetchSession (): ApiActionThunk { const action = { type: 'session', [CALL_API]: { - endpoint: 'session', + endpoint: 'me', method: 'GET', map: (data: Record): Session => { return data as Session @@ -20,9 +20,9 @@ export function fetchSession (): ApiActionThunk { export function signup (username: string, email: string, password: string): ApiActionThunk { return async (dispatch) => { const action = { - type: 'signin', + type: 'signup', [CALL_API]: { - endpoint: 'signin', + endpoint: 'registry/profile/new', method: 'POST', body: { username, @@ -34,3 +34,20 @@ export function signup (username: string, email: string, password: string): ApiA return dispatch(action) } } + +export function signin (username: string, password: string): ApiActionThunk { + return async (dispatch) => { + const action = { + type: 'signin', + [CALL_API]: { + endpoint: 'registry/profile/prove', + method: 'POST', + body: { + username, + password + } + } + } + return dispatch(action) + } +} diff --git a/app/actions/ui.ts b/app/actions/ui.ts index 831f9ca4..6dfbefad 100644 --- a/app/actions/ui.ts +++ b/app/actions/ui.ts @@ -2,13 +2,16 @@ import { UI_TOGGLE_DATASET_LIST, UI_SET_SIDEBAR_WIDTH, UI_ACCEPT_TOS, - UI_SET_PEERNAME, + UI_SET_QRI_CLOUD_AUTHENTICATED, UI_OPEN_TOAST, UI_CLOSE_TOAST, - UI_SET_API_CONNECTION + UI_SET_API_CONNECTION, + UI_SET_MODAL, + UI_SIGNOUT } from '../reducers/ui' import { ToastType } from '../models/store' +import { Modal } from '../models/modals' export const toggleDatasetList = () => { return { @@ -31,9 +34,9 @@ export const acceptTOS = () => { } } -export const setHasSignedUp = () => { +export const setQriCloudAuthenticated = () => { return { - type: UI_SET_PEERNAME + type: UI_SET_QRI_CLOUD_AUTHENTICATED } } @@ -50,9 +53,22 @@ export const closeToast = () => { } } +export const setModal = (modal: Modal) => { + return { + type: UI_SET_MODAL, + payload: modal + } +} + export const setApiConnection = (status: number) => { return { type: UI_SET_API_CONNECTION, status } } + +export const signout = () => { + return { + type: UI_SIGNOUT + } +} diff --git a/app/components/App.tsx b/app/components/App.tsx index dbc41793..cb7381dd 100644 --- a/app/components/App.tsx +++ b/app/components/App.tsx @@ -1,16 +1,15 @@ import * as React from 'react' import { Action } from 'redux' import { CSSTransition } from 'react-transition-group' +import { HashRouter as Router } from 'react-router-dom' +import RoutesContainer from '../containers/RoutesContainer' // import components import Toast from './Toast' -import Onboard from './Onboard' import AppError from './AppError' import AppLoading from './AppLoading' -import NoDatasets from './NoDatasets' import CreateDataset from './modals/CreateDataset' import AddDataset from './modals/AddDataset' -import DatasetContainer from '../containers/DatasetContainer' // import models import { ApiAction } from '../store/api' @@ -23,22 +22,23 @@ export interface AppProps { sessionID: string apiConnection?: number hasAcceptedTOS: boolean - hasSignedUp: boolean - hasSignedIn: boolean + qriCloudAuthenticated: boolean toast: IToast + modal: Modal + children: JSX.Element[] | JSX.Element fetchSession: () => Promise fetchMyDatasets: (page?: number, pageSize?: number) => Promise addDataset: (peername: string, name: string) => Promise setWorkingDataset: (peername: string, name: string, isLinked: boolean) => Promise initDataset: (path: string, name: string, format: string) => Promise acceptTOS: () => Action - setHasSignedUp: () => Action - setHasSignedIn: () => Action + setQriCloudAuthenticated: () => Action signup: (username: string, email: string, password: string) => Promise signin: (username: string, password: string) => Promise closeToast: () => Action setApiConnection: (status: number) => Action pingApi: () => Promise + setModal: (modal: Modal) => Action } interface AppState { @@ -57,7 +57,6 @@ export default class App extends React.Component { this.setModal = this.setModal.bind(this) this.renderModal = this.renderModal.bind(this) - this.renderNoDatasets = this.renderNoDatasets.bind(this) this.renderAppLoading = this.renderAppLoading.bind(this) this.renderAppError = this.renderAppError.bind(this) } @@ -87,12 +86,10 @@ export default class App extends React.Component { } private renderModal (): JSX.Element | null { - // Hide any dialogs while we're displaying an error - // if (errors) { - // return null - // } - const Modal = this.state.currentModal + const { modal, setModal } = this.props + const Modal = modal + if (!Modal) return null return (
{ > this.setState({ currentModal: NoModal })} + onDismissed={async () => setModal(NoModal)} setWorkingDataset={this.props.setWorkingDataset} fetchMyDatasets={this.props.fetchMyDatasets} /> @@ -118,7 +115,7 @@ export default class App extends React.Component { > this.setState({ currentModal: NoModal })} + onDismissed={async () => setModal(NoModal)} setWorkingDataset={this.props.setWorkingDataset} fetchMyDatasets={this.props.fetchMyDatasets} /> @@ -127,20 +124,6 @@ export default class App extends React.Component { ) } - private renderNoDatasets () { - return ( - - < NoDatasets setModal={this.setModal}/> - - ) - } - private renderAppLoading () { return ( { render () { const { - hasSignedUp, - hasSignedIn, - hasAcceptedTOS, - acceptTOS, - signup, - signin, toast, - closeToast, - setHasSignedUp, - setHasSignedIn + closeToast } = this.props - return (
- {this.renderAppLoading()} - {this.renderAppError()} - {this.renderModal()} - - {this.renderNoDatasets()} - { this.props.hasDatasets && } - -
) + + return ( +
+ {this.renderAppLoading()} + {this.renderAppError()} + {this.renderModal()} + + + + +
+ ) } } diff --git a/app/components/ComponentList.tsx b/app/components/ComponentList.tsx index 9b4e918f..fe96a943 100644 --- a/app/components/ComponentList.tsx +++ b/app/components/ComponentList.tsx @@ -2,6 +2,8 @@ import * as React from 'react' import { Action } from 'redux' import classNames from 'classnames' import { DatasetStatus, ComponentType } from '../models/store' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faTags, faArchive, faTh, IconDefinition } from '@fortawesome/free-solid-svg-icons' interface StatusDotProps { status: string | undefined @@ -34,6 +36,7 @@ export const StatusDot: React.FunctionComponent = (props) => { interface FileRowProps { name: string displayName: string + icon?: IconDefinition filename?: string selected?: boolean status?: string @@ -56,6 +59,9 @@ export const FileRow: React.FunctionComponent = (props) => ( }} data-tip={props.tooltip} > + {props.icon && (
+ +
)}
{props.displayName}
{props.filename}
@@ -80,23 +86,25 @@ const components = [ { name: 'meta', displayName: 'Meta', - tooltip: 'title, description, tags, etc' + tooltip: 'title, description, tags, etc', + icon: faTags }, { name: 'body', displayName: 'Body', - tooltip: "the dataset's content" + tooltip: 'the data', + icon: faArchive }, { name: 'schema', displayName: 'Schema', - tooltip: 'the structure of the dataset' + tooltip: 'the structure of the dataset', + icon: faTh } ] -export const getComponentDisplayName = (name: string) => { - const match = components.find(d => d.name === name) - return match && match.displayName +export const getComponentDisplayProps = (name: string) => { + return components.filter(d => d.name === name)[0] } const ComponentList: React.FunctionComponent = (props: ComponentListProps) => { @@ -111,10 +119,10 @@ const ComponentList: React.FunctionComponent = (props: Compo return (
- Dataset Components + Dataset Components
{ - components.map(({ name, displayName, tooltip }) => { + components.map(({ name, displayName, tooltip, icon }) => { if (status[name]) { const { filepath, status: fileStatus } = status[name] let filename @@ -129,6 +137,7 @@ const ComponentList: React.FunctionComponent = (props: Compo key={name} displayName={displayName} name={name} + icon={icon} filename={isLinked ? filename : ''} status={fileStatus} selected={selectedComponent === name} @@ -143,6 +152,7 @@ const ComponentList: React.FunctionComponent = (props: Compo key={name} displayName={displayName} name={displayName} + icon={icon} disabled={true} tooltip={tooltip} /> diff --git a/app/components/Dataset.tsx b/app/components/Dataset.tsx index 5fef305f..110af492 100644 --- a/app/components/Dataset.tsx +++ b/app/components/Dataset.tsx @@ -3,8 +3,12 @@ import classNames from 'classnames' import { Action } from 'redux' import { shell } from 'electron' import ReactTooltip from 'react-tooltip' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faFile, faFolderOpen } from '@fortawesome/free-regular-svg-icons' +import { faLink } from '@fortawesome/free-solid-svg-icons' import { ApiAction, ApiActionThunk } from '../store/api' +import ExternalLink from './ExternalLink' import { Resizable } from './Resizable' import DatasetSidebar from './DatasetSidebar' import DatasetComponent from './DatasetComponent' @@ -16,6 +20,8 @@ import { Modal } from '../models/modals' import { defaultSidebarWidth } from '../reducers/ui' +import { QRI_CLOUD_ROOT } from './App' + import { UI, Selections, @@ -24,6 +30,8 @@ import { Mutations } from '../models/store' +import { Session } from '../models/session' + export interface DatasetProps { // redux state ui: UI @@ -32,6 +40,7 @@ export interface DatasetProps { workingDataset: WorkingDataset mutations: Mutations setModal: (modal: Modal) => void + session: Session // actions toggleDatasetList: () => Action @@ -43,6 +52,7 @@ export interface DatasetProps { fetchWorkingDatasetDetails: () => Promise fetchWorkingHistory: (page?: number, pageSize?: number) => ApiActionThunk fetchWorkingStatus: () => Promise + signout: () => Action } interface DatasetState { @@ -149,7 +159,8 @@ export default class Dataset extends React.Component { render () { // unpack all the things - const { ui, selections, workingDataset, setModal } = this.props + const { ui, selections, workingDataset, setModal, session } = this.props + const { peername: username, photo: userphoto } = session const { showDatasetList, datasetSidebarWidth } = ui const { name, @@ -167,7 +178,8 @@ export default class Dataset extends React.Component { setActiveTab, setSidebarWidth, setSelectedListItem, - fetchWorkingHistory + fetchWorkingHistory, + signout } = this.props const linkButton = isLinked ? ( @@ -177,7 +189,7 @@ export default class Dataset extends React.Component { onClick={() => { shell.openItem(workingDataset.linkpath) }} >
- openfolder +
Show Dataset Files
@@ -185,7 +197,10 @@ export default class Dataset extends React.Component {
) : (
- link + + + +
Link to Filesystem
@@ -193,6 +208,38 @@ export default class Dataset extends React.Component {
) + const UserMenu = () => { + const [showMenu, setShowMenu] = React.useState(false) + + return ( +
{ setShowMenu(!showMenu) }} + > +
+ +
+
+
{username}
+
+ { + showMenu + ?
 
+ :
 
+ } + { + showMenu && ( +
    +
  • Public Profile
  • +
  • Settings
  • +
  • Sign Out
  • +
+ ) + } +
+ ) + } + return (
@@ -217,6 +264,7 @@ export default class Dataset extends React.Component {
{linkButton} +
= (props: DatasetComponentProps) => { const { component, componentStatus, isLoading, history = false } = props - return ( -
-
-
- {getComponentDisplayName(component)} -
-
- {componentStatus && } -
-
-
- -
- -
-
- -
- + if (component === 'meta' || component === 'body' || component === 'schema') { + const { displayName, icon } = getComponentDisplayProps(component) + + return ( +
+
+
+ {displayName}
- - -
- +
+ {componentStatus && }
- - +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
-
- ) + ) + } else { + return
No Component Selected Screen
+ } } - export default DatasetComponent diff --git a/app/components/DatasetList.tsx b/app/components/DatasetList.tsx index cd4270ab..3567395a 100644 --- a/app/components/DatasetList.tsx +++ b/app/components/DatasetList.tsx @@ -2,6 +2,8 @@ import * as React from 'react' import { Action, AnyAction } from 'redux' import classNames from 'classnames' import { MyDatasets, WorkingDataset } from '../models/store' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faFolderOpen } from '@fortawesome/free-regular-svg-icons' import { Modal, ModalType } from '../models/modals' @@ -62,7 +64,7 @@ export default class DatasetList extends React.Component {
{name}
- {isLinked && openfolder} + {isLinked && }
diff --git a/app/components/Onboard.tsx b/app/components/Onboard.tsx deleted file mode 100644 index 1090878f..00000000 --- a/app/components/Onboard.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as React from 'react' -import { Action } from 'redux' -import { ApiAction } from '../store/api' -import { CSSTransition } from 'react-transition-group' - -import Welcome from './Welcome' -import Signup from './Signup' -import Signin from './Signin' - -export interface OnboardProps { - hasAcceptedTOS: boolean - hasSignedUp: boolean - hasSignedIn: boolean - acceptTOS: () => Action - setHasSignedUp: () => Action - setHasSignedIn: () => Action - signup: (username: string, email: string, password: string) => Promise - signin: (username: string, password: string) => Promise -} - -// Onboard is a series of flows for onboarding a new user -const Onboard: React.FunctionComponent = ( - { - hasAcceptedTOS, - hasSignedUp, - hasSignedIn, - acceptTOS, - signup, - setHasSignedUp, - signin, - setHasSignedIn - }) => { - const renderWelcome = () => { - return ( - - - - ) - } - - const renderSignup = () => { - return ( - - - - ) - } - - const renderSignin = () => { - return ( - - - - ) - } - - return ( -
- {renderWelcome()} - {renderSignup()} - {renderSignin()} -
- ) -} - -export default Onboard diff --git a/app/components/Signin.tsx b/app/components/Signin.tsx index aefad88f..27cafbe1 100644 --- a/app/components/Signin.tsx +++ b/app/components/Signin.tsx @@ -1,19 +1,23 @@ import * as React from 'react' +import { Action } from 'redux' +import { Link } from 'react-router-dom' + import WelcomeTemplate from './WelcomeTemplate' import DebouncedTextInput from './form/DebouncedTextInput' import getActionType from '../utils/actionType' import { ApiAction } from '../store/api' -import { Action } from 'redux' import { validateUsername, validatePassword } from '../utils/formValidation' export interface SigninProps { signin: (username: string, password: string) => Promise - setHasSignedIn: () => Action + onSuccess: () => Action } const Signin: React.FunctionComponent = (props: SigninProps) => { - const { signin, setHasSignedIn } = props + const { signin, onSuccess } = props + + const [serverError, setServerError] = React.useState() // track two form values const [username, setUsername] = React.useState('') @@ -54,17 +58,12 @@ const Signin: React.FunctionComponent = (props: SigninProps) => { signin(username, password) .then((action) => { setLoading(false) - // TODO (ramfox): possibly these should move to the reducer + // TODO (chriswhong): the server should be able to return field-specific errors + // e.g. err.username = 'The username is not available' if (getActionType(action) === 'failure') { - const { errors } = action.payload.data - const { username, password } = errors - - // set server-side errors for each field - username && setUsernameError(username) - password && setPasswordError(password) + setServerError(action.payload.err.message) } else { - // SUCCESS! - setHasSignedIn() + onSuccess() } }) }) @@ -76,10 +75,10 @@ const Signin: React.FunctionComponent = (props: SigninProps) => { acceptEnabled={acceptEnabled} acceptText='Take me to Qri ' title='Sign in to Qri' - subtitle={'Don\'t have an account? Sign Up'} loading={loading} id='signin-page' > +
Don't have an account yet? Sign Up
= (props: SigninProps) => { value={password} errorText={passwordError} onChange={handleChange} /> +
{ serverError }
) diff --git a/app/components/Signup.tsx b/app/components/Signup.tsx index 4ee69920..8e42891f 100644 --- a/app/components/Signup.tsx +++ b/app/components/Signup.tsx @@ -1,20 +1,23 @@ import * as React from 'react' +import { Action } from 'redux' +import { Link } from 'react-router-dom' + import WelcomeTemplate from './WelcomeTemplate' import DebouncedTextInput from './form/DebouncedTextInput' import getActionType from '../utils/actionType' import { ApiAction } from '../store/api' -import { Action } from 'redux' import { validateUsername, validateEmail, validatePassword } from '../utils/formValidation' export interface SignupProps { signup: (username: string, email: string, password: string) => Promise - setHasSignedUp: () => Action + onSuccess: () => Action } const Signup: React.FunctionComponent = (props: SignupProps) => { - const { signup, setHasSignedUp } = props + const { signup, onSuccess } = props + const [serverError, setServerError] = React.useState() // track three form values const [username, setUsername] = React.useState('') const [email, setEmail] = React.useState('') @@ -59,18 +62,12 @@ const Signup: React.FunctionComponent = (props: SignupProps) => { signup(username, email, password) .then((action) => { setLoading(false) - // TODO (ramfox): possibly these should move to the reducer + // TODO (chriswhong): the server should be able to return field-specific errors + // e.g. err.username = 'The username is not available' if (getActionType(action) === 'failure') { - const { errors } = action.payload.data - const { username, email, password } = errors - - // set server-side errors for each field - username && setUsernameError(username) - email && setEmailError(email) - password && setPasswordError(password) + setServerError(action.payload.err.message) } else { - // SUCCESS! - setHasSignedUp() + onSuccess() } }) }) @@ -82,10 +79,10 @@ const Signup: React.FunctionComponent = (props: SignupProps) => { acceptEnabled={acceptEnabled} acceptText='Take me to Qri ' title='Sign up for Qri' - subtitle='Already have an account? Sign In' loading={loading} id='signup-page' > +
Already have account? Sign In
= (props: SignupProps) => { maxLength={100} value={username} errorText={usernameError} - onChange={handleChange} /> + onChange={handleChange} + /> = (props: SignupProps) => { maxLength={100} value={email} errorText={emailError} - onChange={handleChange} /> + onChange={handleChange} + /> = (props: SignupProps) => { maxLength={100} value={password} errorText={passwordError} - onChange={handleChange} /> + onChange={handleChange} + /> +
{ serverError }
) diff --git a/app/components/WelcomeTemplate.tsx b/app/components/WelcomeTemplate.tsx index 03c8fa34..ee249955 100644 --- a/app/components/WelcomeTemplate.tsx +++ b/app/components/WelcomeTemplate.tsx @@ -9,7 +9,7 @@ interface WelcomeTemplateProps { acceptText?: string exit?: boolean title: string - subtitle: string + subtitle?: string loading?: boolean id?: string acceptEnabled?: boolean @@ -28,7 +28,7 @@ const WelcomeTemplate: React.FunctionComponent = ({ onAcce

{title}

-
+ { subtitle &&
{subtitle}
}
{children} diff --git a/app/containers/AppContainer.tsx b/app/containers/AppContainer.tsx index 69204188..7717d307 100644 --- a/app/containers/AppContainer.tsx +++ b/app/containers/AppContainer.tsx @@ -11,9 +11,10 @@ import { import { acceptTOS, - setHasSignedUp, + setQriCloudAuthenticated, closeToast, - setApiConnection + setApiConnection, + setModal } from '../actions/ui' import { @@ -35,15 +36,14 @@ const AppContainer = connect( const loading = ui.apiConnection === 0 const hasDatasets = myDatasets.value.length !== 0 const { id: sessionID, peername } = session - const { hasAcceptedTOS, hasSignedUp, apiConnection, toast } = ui + const { apiConnection, toast, modal } = ui return { - hasAcceptedTOS, - hasSignedUp, hasDatasets, loading, sessionID, peername, toast, + modal, apiConnection } }, @@ -52,13 +52,14 @@ const AppContainer = connect( fetchMyDatasets, acceptTOS, signup, - setHasSignedUp, + setQriCloudAuthenticated, addDataset: addDatasetAndFetch, initDataset: initDatasetAndFetch, closeToast, pingApi, setApiConnection, - setWorkingDataset + setWorkingDataset, + setModal }, mergeProps )(App) diff --git a/app/containers/DatasetContainer.tsx b/app/containers/DatasetContainer.tsx index 7c1fab6b..3d29c921 100644 --- a/app/containers/DatasetContainer.tsx +++ b/app/containers/DatasetContainer.tsx @@ -4,7 +4,7 @@ import Dataset, { DatasetProps } from '../components/Dataset' import { Modal } from '../models/modals' import Store from '../models/store' -import { toggleDatasetList, setSidebarWidth } from '../actions/ui' +import { toggleDatasetList, setSidebarWidth, signout } from '../actions/ui' import { fetchWorkingDatasetDetails, fetchWorkingStatus, @@ -32,7 +32,8 @@ const DatasetContainer = connect( selections, myDatasets, workingDataset, - mutations + mutations, + session } = state const { setModal } = ownProps @@ -42,7 +43,8 @@ const DatasetContainer = connect( myDatasets, workingDataset, mutations, - setModal + setModal, + session }, ownProps) }, { @@ -54,7 +56,8 @@ const DatasetContainer = connect( setWorkingDataset, fetchWorkingDatasetDetails, fetchWorkingStatus, - fetchWorkingHistory + fetchWorkingHistory, + signout }, mergeProps )(Dataset) diff --git a/app/containers/RoutesContainer.tsx b/app/containers/RoutesContainer.tsx index 770ab14a..9ebbd387 100644 --- a/app/containers/RoutesContainer.tsx +++ b/app/containers/RoutesContainer.tsx @@ -2,9 +2,29 @@ import { connect } from 'react-redux' import Routes from '../routes' import Store from '../models/store' +import { + acceptTOS, + setQriCloudAuthenticated, + setModal +} from '../actions/ui' + +import { signup, signin } from '../actions/session' + const mapStateToProps = (state: Store) => { - const { ui } = state - return { ui } + const { ui, myDatasets } = state + const hasDatasets = myDatasets.value.length !== 0 + const { qriCloudAuthenticated, hasAcceptedTOS } = ui + return { + qriCloudAuthenticated, + hasAcceptedTOS, + hasDatasets + } } -export default connect(mapStateToProps)(Routes) +export default connect(mapStateToProps, { + signup, + signin, + acceptTOS, + setQriCloudAuthenticated, + setModal +})(Routes) diff --git a/app/index.tsx b/app/index.tsx index 13a9d952..4984de43 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,7 +1,8 @@ import * as React from 'react' import * as ReactDOM from 'react-dom' -import AppContainer from './containers/AppContainer' import { Provider } from 'react-redux' +import AppContainer from './containers/AppContainer' + import './app.global.scss' const { configureStore } = require('./store/configureStore') // eslint-disable-line diff --git a/app/models/store.ts b/app/models/store.ts index b2193799..c1c4f61d 100644 --- a/app/models/store.ts +++ b/app/models/store.ts @@ -50,7 +50,7 @@ export interface UI { apiConnection: ApiConnection showDatasetList: boolean hasAcceptedTOS: boolean - hasSignedUp: boolean + qriCloudAuthenticated: boolean modal?: Modal showDiff: boolean datasetSidebarWidth: number @@ -58,13 +58,15 @@ export interface UI { toast: Toast } +export type SelectedComponent = 'meta' | 'body' | 'schema' | '' + // currently selected dataset, tab, dataset component, commit, etc export interface Selections { peername: string | null name: string | null isLinked: boolean activeTab: string - component: string + component: SelectedComponent commit: string commitComponent: string } diff --git a/app/reducers/index.ts b/app/reducers/index.ts index 62e24e89..57f11c2e 100644 --- a/app/reducers/index.ts +++ b/app/reducers/index.ts @@ -15,7 +15,8 @@ const initialSession: Session = { peername: '', id: '', created: '', - updated: '' + updated: '', + photo: 'https://avatars0.githubusercontent.com/u/1833820?s=23&v=4' } const [SESSION_REQ, SESSION_SUCC, SESSION_FAIL] = apiActionTypes('session') @@ -26,7 +27,12 @@ const sessionReducer: Reducer = (state = initialSession, action: AnyAction) => { case SESSION_REQ || SET_PEERNAME_REQ: return state case SESSION_SUCC || SET_PEERNAME_SUCC: - return Object.assign({}, state, action.payload.data) + return { + ...state, + ...action.payload.data, + // hard code this photo until the backend provides user photos + photo: 'https://avatars0.githubusercontent.com/u/1833820?s=23&v=4' + } case SESSION_FAIL || SET_PEERNAME_FAIL: return state default: diff --git a/app/reducers/selections.ts b/app/reducers/selections.ts index 2780f4be..2c61795c 100644 --- a/app/reducers/selections.ts +++ b/app/reducers/selections.ts @@ -1,6 +1,6 @@ import { AnyAction } from 'redux' -import { Selections } from '../models/store' -import store from '../utils/localStore' +import { Selections, SelectedComponent } from '../models/store' +import localStore from '../utils/localStore' import { apiActionTypes } from '../store/api' export const SELECTIONS_SET_ACTIVE_TAB = 'SELECTIONS_SET_ACTIVE_TAB' @@ -8,13 +8,13 @@ export const SELECTIONS_SET_SELECTED_LISTITEM = 'SELECTIONS_SET_SELECTED_LISTITE export const SELECTIONS_SET_WORKING_DATASET = 'SELECTIONS_SET_WORKING_DATASET' const initialState: Selections = { - peername: store().getItem('peername') || '', - name: store().getItem('name') || '', - isLinked: store().getItem('isLinked') === 'true', - activeTab: store().getItem('activeTab') || 'status', - component: store().getItem('component') || '', - commit: store().getItem('commit') || '', - commitComponent: store().getItem('commitComponent') || '' + peername: localStore().getItem('peername') || '', + name: localStore().getItem('name') || '', + isLinked: localStore().getItem('isLinked') === 'true', + activeTab: localStore().getItem('activeTab') || 'status', + component: localStore().getItem('component') as SelectedComponent || '', + commit: localStore().getItem('commit') || '', + commitComponent: localStore().getItem('commitComponent') || '' } const [, LIST_SUCC] = apiActionTypes('list') @@ -24,30 +24,30 @@ export default (state = initialState, action: AnyAction) => { switch (action.type) { case SELECTIONS_SET_ACTIVE_TAB: const { activeTab } = action.payload - store().setItem('activeTab', activeTab) + localStore().setItem('activeTab', activeTab) return Object.assign({}, state, { activeTab }) case SELECTIONS_SET_SELECTED_LISTITEM: const { type, selectedListItem } = action.payload if (type === 'component') { - store().setItem('component', selectedListItem) + localStore().setItem('component', selectedListItem) return Object.assign({}, state, { component: selectedListItem }) } if (type === 'commit') { - store().setItem('commit', selectedListItem) + localStore().setItem('commit', selectedListItem) return Object.assign({}, state, { commit: selectedListItem }) } if (type === 'commitComponent') { - store().setItem('commitComponent', selectedListItem) + localStore().setItem('commitComponent', selectedListItem) return Object.assign({}, state, { commitComponent: selectedListItem }) } return state case SELECTIONS_SET_WORKING_DATASET: const { peername, name, isLinked } = action.payload - store().setItem('peername', peername) - store().setItem('name', name) - store().setItem('isLinked', isLinked) + localStore().setItem('peername', peername) + localStore().setItem('name', name) + localStore().setItem('isLinked', isLinked) return Object.assign({}, state, { peername, name, isLinked }) case LIST_SUCC: @@ -55,9 +55,9 @@ export default (state = initialState, action: AnyAction) => { if (state.peername === '' && state.name === '') { if (action.payload.data.length === 0) return state const { peername: firstPeername, name: firstName, isLinked: firstIsLinked } = action.payload.data[0] - store().setItem('peername', firstPeername) - store().setItem('name', firstName) - store().setItem('isLinked', firstIsLinked) + localStore().setItem('peername', firstPeername) + localStore().setItem('name', firstName) + localStore().setItem('isLinked', firstIsLinked) return Object.assign({}, state, { peername: firstPeername, name: firstName, isLinked: firstIsLinked }) } else { return state @@ -66,8 +66,8 @@ export default (state = initialState, action: AnyAction) => { // when a new dataset is added via the modal, make it the selected dataset case ADD_SUCC: const { peername: newPeername, name: newName } = action.payload.data - store().setItem('peername', newPeername) - store().setItem('name', newName) + localStore().setItem('peername', newPeername) + localStore().setItem('name', newName) return { ...state, peername: newPeername, diff --git a/app/reducers/ui.ts b/app/reducers/ui.ts index e73d388f..91e74eb4 100644 --- a/app/reducers/ui.ts +++ b/app/reducers/ui.ts @@ -6,14 +6,16 @@ import { SAVE_SUCC, SAVE_FAIL } from '../reducers/mutations' export const UI_TOGGLE_DATASET_LIST = 'UI_TOGGLE_DATASET_LIST' export const UI_SET_SIDEBAR_WIDTH = 'UI_SET_SIDEBAR_WIDTH' export const UI_ACCEPT_TOS = 'UI_ACCEPT_TOS' -export const UI_SET_PEERNAME = 'UI_SET_PEERNAME' +export const UI_SET_QRI_CLOUD_AUTHENTICATED = 'UI_SET_QRI_CLOUD_AUTHENTICATED' export const UI_OPEN_TOAST = 'UI_OPEN_TOAST' export const UI_CLOSE_TOAST = 'UI_CLOSE_TOAST' export const UI_SET_API_CONNECTION = 'UI_SET_API_CONNECTION' +export const UI_SET_MODAL = 'UI_SET_MODAL' +export const UI_SIGNOUT = 'UI_SIGNOUT' export const defaultSidebarWidth = 250 export const hasAcceptedTOSKey = 'acceptedTOS' -export const hasSignedUpKey = 'setPeername' +export const qriCloudAuthenticatedKey = 'qriCloudAuthenticated' const [, HEALTH_SUCCESS] = apiActionTypes('health') @@ -35,7 +37,7 @@ const initialState = { apiConnection: 0, showDatasetList: false, hasAcceptedTOS: store().getItem(hasAcceptedTOSKey) === 'true', - hasSignedUp: store().getItem(hasSignedUpKey) === 'true', + qriCloudAuthenticated: store().getItem(qriCloudAuthenticatedKey) === 'true', showDiff: false, datasetSidebarWidth: getSidebarWidth('datasetSidebarWidth'), commitSidebarWidth: getSidebarWidth('commitSidebarWidth'), @@ -64,9 +66,9 @@ export default (state = initialState, action: AnyAction) => { store().setItem(hasAcceptedTOSKey, 'true') return Object.assign({}, state, { hasAcceptedTOS: true }) - case UI_SET_PEERNAME: - store().setItem(hasSignedUpKey, 'true') - return Object.assign({}, state, { hasSignedUp: true }) + case UI_SET_QRI_CLOUD_AUTHENTICATED: + store().setItem(qriCloudAuthenticatedKey, 'true') + return Object.assign({}, state, { qriCloudAuthenticated: true }) case UI_OPEN_TOAST: const { type: toastType, message } = action.payload @@ -88,6 +90,13 @@ export default (state = initialState, action: AnyAction) => { } } + case UI_SET_MODAL: + const modal = action.payload + return { + ...state, + modal + } + // listen for SAVE_SUCC and SAVE_FAIL to set the toast case SAVE_SUCC: return { @@ -114,6 +123,13 @@ export default (state = initialState, action: AnyAction) => { return Object.assign({}, state, { apiConnection: 1 }) case UI_SET_API_CONNECTION: return Object.assign({}, state, { apiConnection: action.status }) + + case UI_SIGNOUT: + store().setItem('qriCloudAuthenticated', 'false') + return { + ...state, + qriCloudAuthenticated: false + } default: return state } diff --git a/app/routes.tsx b/app/routes.tsx index 9d360f93..992c0efe 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -1,14 +1,64 @@ import * as React from 'react' -import { Switch, Route } from 'react-router' -import AppContainer from './containers/AppContainer' +import { Switch, Route, Redirect } from 'react-router-dom' +import Welcome from './components/Welcome' +import Signup from './components/Signup' +import Signin from './components/Signin' +import NoDatasets from './components/NoDatasets' import DatasetContainer from './containers/DatasetContainer' -export default function Routes () { +export default function Routes (props: any) { + const { + hasAcceptedTOS, + qriCloudAuthenticated, + hasDatasets, + setQriCloudAuthenticated, + acceptTOS, + signup, + signin, + setModal + } = props + return ( - - - - - + + + + {/* Welcome page (Accept TOS) */} + { + if (hasAcceptedTOS) return + return + }} /> + + {/* Sign Up */} + { + if (!hasAcceptedTOS) return + if (qriCloudAuthenticated) return + return + }} /> + + {/* Sign In */} + { + if (!hasAcceptedTOS) return + if (qriCloudAuthenticated) return + return + }} /> + + {/* Dataset */} + { + if (!hasAcceptedTOS) return + if (!qriCloudAuthenticated) return + if (!hasDatasets) return + return + }}/> + + {/* No Datasets */} + { + if (!hasAcceptedTOS) return + if (!qriCloudAuthenticated) return + + if (hasDatasets) return + return + }}/> + + ) } diff --git a/app/scss/_dataset.scss b/app/scss/_dataset.scss index 12050a1b..5f55305a 100644 --- a/app/scss/_dataset.scss +++ b/app/scss/_dataset.scss @@ -21,22 +21,54 @@ $header-font-size: .9rem; flex-direction: row; flex-grow: 0; flex-shrink: 0; + + // Header Dropdown Menu + + .dropdown { + position: absolute; + list-style-type: none; + padding: 0; + background: #FFF; + color: #000; + border: 1px solid #b1b1b1; + top: 50px; + right: 0px; + z-index: 1; + text-align: right; + font-size: .75rem; + + li { + padding: 6px 13px; + + a { + color: #000; + } + + &:hover { + background: #ddd + } + } + } } + $header-column-border: 1px solid #66737b; + .header-column { height: 100%; - border-right: 1px solid #66737b; + border-right: $header-column-border; padding: 0 9px; display: inline-flex; flex-direction: row; align-items: center; min-width: 164px; + &:last-child { + float: right; + border-left: $header-column-border; + } + .header-column-icon { flex: 0 0 32px; - height: 32px; - width: 32px; - margin-right:10px; .icon-inline { font-size: 1.2rem; @@ -44,17 +76,17 @@ $header-font-size: .9rem; top: 5px; left: 7px; } + + } .header-column-text { - flex: 1; flex-grow: 1; overflow-x: hidden; .label { font-size: 0.75rem; font-weight: 300; - line-height: 0.8rem; } .name { @@ -78,6 +110,26 @@ $header-font-size: .9rem; &.expanded:hover { background-color: transparent; } + + .arrow { + width: 0px; + height: 0px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + position: absolute; + + &.expand { + border-top: 5px solid #fff; + right: 15px; + top: 24px; + } + + &.collapse { + border-bottom: 5px solid #fff; + right: 15px; + top: 24px; + } + } } .terminal { @@ -186,29 +238,13 @@ $header-font-size: .9rem; z-index:10; display: inline-flex; - .arrow { - width: 0px; - height: 0px; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - position: absolute; - - &.expand { - border-top: 5px solid #fff; - right: 15px; - top: 24px; - } - - &.collapse { - border-bottom: 5px solid #383838; - right: 15px; - top: 24px; - } - } - &.expanded { color: #383838; } + + .arrow.collapse { + border-bottom: 5px solid #383838; + } } .dataset-list { @@ -228,9 +264,18 @@ $header-font-size: .9rem; border-radius: 2px; } + .icon-column { + flex-basis: 27px; + text-align: center; + color: #315379; + display: flex; + align-self: center; + } + .text-column { flex-direction: column; flex: 1; + align-self: center; .text { white-space: nowrap; @@ -378,4 +423,4 @@ $header-font-size: .9rem; padding: $sidebar-item-padding; border-bottom: $list-item-bottom-border; } -} \ No newline at end of file +} diff --git a/app/scss/_welcome.scss b/app/scss/_welcome.scss index d6228416..6e26cc35 100644 --- a/app/scss/_welcome.scss +++ b/app/scss/_welcome.scss @@ -32,6 +32,12 @@ justify-content: space-evenly; } +.welcome-content { + h6 { + text-align: center + } +} + .welcome-accept { display: flex; flex-direction: row-reverse; diff --git a/app/scss/style.scss b/app/scss/style.scss index 106371f9..872a34b1 100755 --- a/app/scss/style.scss +++ b/app/scss/style.scss @@ -16,7 +16,6 @@ @import "handsontable"; @import "chrome"; -@import "dataset"; @import "editor"; @import "schema"; @import "form"; diff --git a/app/store/api.ts b/app/store/api.ts index 93b5735d..799e0832 100644 --- a/app/store/api.ts +++ b/app/store/api.ts @@ -103,35 +103,12 @@ async function getJSON (url: string, options: FetchOptions): Promise { return res as T } -// endpointMap is an object that maps frontend endpoint names to their -// corresponding API url path -const endpointMap: Record = { - 'list': 'list', - 'dataset': '', // dataset endpoints are constructured through query param values - 'body': 'body', // dataset endpoints are constructured through query param values - 'history': 'history', - 'status': 'status', - 'save': 'save', - 'session': 'me', - 'health': 'health', - 'add': 'add', - 'init': 'init/', - 'ping': 'health', - 'signin': 'signin' -} - function apiUrl (endpoint: string, segments?: ApiSegments, query?: ApiQuery, pageInfo?: ApiPagination): [string, string] { - const path = endpointMap[endpoint] - if (path === undefined) { - return ['', `${endpoint} is not a valid api endpoint`] - } - const addToUrl = (url: string, seg: string): string => { if (!(url[url.length - 1] === '/' || seg[0] === '/')) url += '/' return url + seg } - - let url = `http://localhost:2503/${path}` + let url = `http://localhost:2503/${endpoint}` if (segments) { if (segments.peername) { url = addToUrl(url, segments.peername) diff --git a/app/utils/localstore.ts b/app/utils/localstore.ts index c93c1984..05a8a55d 100644 --- a/app/utils/localstore.ts +++ b/app/utils/localstore.ts @@ -24,7 +24,7 @@ interface Storage { } } -export default function store () { +export default function localStore () { if (window.localStorage) { return window.localStorage } diff --git a/package.json b/package.json index 3bac1e97..40ee45f4 100644 --- a/package.json +++ b/package.json @@ -125,11 +125,11 @@ "@types/react-router": "5.0.3", "@types/react-router-dom": "4.3.4", "@types/react-router-redux": "5.0.18", + "@types/react-tooltip": "3.9.3", "@types/react-transition-group": "2.9.2", "@types/redux-logger": "3.0.7", - "@types/react-tooltip": "3.9.3", - "@types/underscore": "1.9.2", "@types/sinon": "7.0.13", + "@types/underscore": "1.9.2", "@typescript-eslint/eslint-plugin": "1.11.0", "@typescript-eslint/parser": "1.11.0", "asar": "2.0.1", @@ -179,6 +179,10 @@ "webpack-merge": "4.2.1" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "1.2.22", + "@fortawesome/free-regular-svg-icons": "5.10.2", + "@fortawesome/free-solid-svg-icons": "5.10.2", + "@fortawesome/react-fontawesome": "0.1.4", "@handsontable/react": "3.0.0", "classnames": "2.2.6", "electron-debug": "3.0.1", diff --git a/yarn.lock b/yarn.lock index bbafcc46..61dad2c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -138,6 +138,40 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@fortawesome/fontawesome-common-types@^0.2.22": + version "0.2.22" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.22.tgz#3f1328d232a0fd5de8484d833c8519426f39f016" + integrity sha512-QmEuZsipX5/cR9JOg0fsTN4Yr/9lieYWM8AQpmRa0eIfeOcl/HLYoEa366BCGRSrgNJEexuvOgbq9jnJ22IY5g== + +"@fortawesome/fontawesome-svg-core@1.2.22": + version "1.2.22" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.22.tgz#9a6117c96c8b823c7d531000568ac75c3c02e123" + integrity sha512-Q941E4x8UfnMH3308n0qrgoja+GoqyiV846JTLoCcCWAKokLKrixCkq6RDBs8r+TtAWaLUrBpI+JFxQNX/WNPQ== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.22" + +"@fortawesome/free-regular-svg-icons@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.10.2.tgz#e4ada1c15f42133ad92761418a9b0e2d407fb022" + integrity sha512-Qk4FmwXuRDY5K2GyiKt7adCN204dTlTb0Ps3/JU4BfYoCrU43DResd1QZxfcoQJfV2kw29spZ4+BDL+9IRyj1Q== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.22" + +"@fortawesome/free-solid-svg-icons@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.10.2.tgz#61bcecce3aa5001fd154826238dfa840de4aa05a" + integrity sha512-9Os/GRUcy+iVaznlg8GKcPSQFpIQpAg14jF0DWsMdnpJfIftlvfaQCWniR/ex9FoOpSEOrlXqmUCFL+JGeciuA== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.22" + +"@fortawesome/react-fontawesome@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.4.tgz#18d61d9b583ca289a61aa7dccc05bd164d6bc9ad" + integrity sha512-GwmxQ+TK7PEdfSwvxtGnMCqrfEm0/HbRHArbUudsYiy9KzVCwndxa2KMcfyTQ8El0vROrq8gOOff09RF1oQe8g== + dependencies: + humps "^2.0.1" + prop-types "^15.5.10" + "@handsontable/react@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@handsontable/react/-/react-3.0.0.tgz#70fb0b359b4672eb3da1d70000b26950c7b2e1e1" @@ -3656,6 +3690,11 @@ humanize-plus@^1.8.1: version "1.8.2" resolved "https://registry.yarnpkg.com/humanize-plus/-/humanize-plus-1.8.2.tgz#a65b34459ad6367adbb3707a82a3c9f916167030" +humps@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao= + iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -5994,7 +6033,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.5.4, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" dependencies: @@ -7538,6 +7577,11 @@ ts-node@8.3.0: source-map-support "^0.5.6" yn "^3.0.0" +ts-type-guards@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/ts-type-guards/-/ts-type-guards-0.6.1.tgz#44fec2f35bfa8d78eeb50476ce22e86954804e62" + integrity sha512-YplsxDSkqPoLH/QiLqMpIc8+VGTx6q58qAvVHMC0SzL10JsF529elVqjIgShFiBL9U2yBw+dQYxDDCNPDDJyQw== + tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" @@ -7737,7 +7781,7 @@ url@^0.11.0, url@~0.11.0: punycode "1.3.2" querystring "0.2.0" -use-debounce@^3.0.0: +use-debounce@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-3.0.0.tgz#d0da8b8919b9cfdeccc9af47c0747e3ccb6dfbac" integrity sha512-EvWGIEpn2+S8C7UKlYe9U7Kq2N9u+YDd/1nYRp3d0F5PX0KQCHqZrNbhSvKDMaoycsAnNh+2hmkLy/N48/NZbQ==