diff --git a/src/app/Home.tsx b/src/app/Home.tsx index c76db620..44d1f189 100644 --- a/src/app/Home.tsx +++ b/src/app/Home.tsx @@ -4,9 +4,8 @@ import * as React from 'react'; -import { Link } from 'wouter'; +import { Link, useLocation } from 'wouter'; import clsx from 'clsx'; -import { styled } from '@mui/material/styles'; import AppBar from '@mui/material/AppBar'; import Button from '@mui/material/Button'; import Grid from '@mui/material/Grid'; @@ -19,7 +18,6 @@ import Paper from '@mui/material/Paper'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import Avatar from '@mui/material/Avatar'; -import { List } from 'immutable'; import { PopoverOrigin } from '@mui/material/Popover'; import AccountCircle from '@mui/icons-material/AccountCircle'; import MenuIcon from '@mui/icons-material/Menu'; @@ -27,11 +25,9 @@ import MenuIcon from '@mui/icons-material/Menu'; import { NewProject } from './NewProject'; import { Project } from './Project'; import { User } from './User'; - -interface HomeState { - anchorEl?: HTMLElement; - projects: List; -} +import { Suspense, useEffect, useRef, useState } from 'react'; +import { useMediaQuery } from '@mui/system'; +import { styled } from '@mui/material'; interface HomeProps { user: User; @@ -44,162 +40,141 @@ const AnchorOrigin: PopoverOrigin = { horizontal: 'right', }; -const Home = styled( - class HomeInner extends React.Component { - state: HomeState; - - constructor(props: HomeProps) { - super(props); - - this.state = { - anchorEl: undefined, - projects: List(), - }; - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - setTimeout(this.getProjects); - } - - getProjects = async (): Promise => { - const response = await fetch('/api/projects', { credentials: 'same-origin' }); - const status = response.status; - if (!(status >= 200 && status < 400)) { - console.log("couldn't fetch projects."); - return; - } - const projects = (await response.json()) as Project[]; - this.setState({ - projects: List(projects), - }); - }; - - handleClose = () => { - this.setState({ - anchorEl: undefined, - }); - }; - - handleMenu = (event: React.MouseEvent) => { - this.setState({ - anchorEl: event.currentTarget, - }); - }; +async function fetchProjects() { + const response = await fetch('/api/projects', { credentials: 'same-origin' }); + const status = response.status; + if (!(status >= 200 && status < 400)) { + console.log("Couldn't fetch projects."); + return []; + } + return (await response.json()) as Project[]; +} - handleProjectCreated = (project: Project) => { - window.location.pathname = '/' + project.id; - }; +function NewProjectForm({ user }: { user: User }) { + const [, navigate] = useLocation(); + function handleProjectCreated(project: Project) { + navigate('/' + project.id); + } + return ( +
+ + + + + +
+ ); +} - getGridListCols = () => { - // TODO: this should be 1 on small screens, but useMediaQuery doesn't - // work in class components, only function components. - return 2; +function Projects() { + const isLargeScreen = useMediaQuery('(min-width:600px)'); + const gridListCols = isLargeScreen ? 2 : 1; + const [projects, setProjects] = useState([]); + + useEffect(() => { + let ignore = false; + fetchProjects().then((projects) => { + if (!ignore) setProjects(projects); + }); + return () => { + ignore = true; }; + }, []); + + return ( +
+ + {projects.map((project) => ( + + + +
+ model preview +
+ + {project.displayName} + + {project.description}  +
+ +
+ ))} +
+
+ ); +} - newProjectForm() { - return ( -
- - - - - -
- ); - } - - projects() { - const { projects } = this.state; - return ( -
- - {projects.map((project) => ( - - - -
- model preview -
- - {project.displayName} - - {project.description}  -
- -
- ))} -
-
- ); - } - - render() { - const { className } = this.props; - const { anchorEl } = this.state; - const { photoUrl } = this.props.user; - const open = Boolean(anchorEl); - - const account = photoUrl ? ( - +function Home(props: HomeProps & { className?: string }) { + const anchorEl = useRef(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const account = props.user.photoUrl ? ( + + ) : ( + + ); + + return ( +
+ + + + + + + + Simlin + + {/* */} + {/**/} + {/* System Dynamics*/} + {/**/} + +
+ + + + + setIsMenuOpen(true)} + color="inherit" + ref={anchorEl} + > + {account} + + setIsMenuOpen(false)} + > + setIsMenuOpen(false)}>Logout + +
+
+
+
+
+
+ {props.isNewProject ? ( + ) : ( - - ); - - const content = this.props.isNewProject ? this.newProjectForm() : this.projects(); - - return ( -
- - - - - - - - Simlin - - {/* */} - {/**/} - {/* System Dynamics*/} - {/**/} - -
- - - + + + + )} +
+ ); +} - - {account} - - - Logout - -
- - -
-
-
- {content} -
- ); - } - }, -)(() => ({ +export default styled(Home)({ '&.simlin-home-root': { flexGrow: 1, }, @@ -253,6 +228,4 @@ const Home = styled( marginRight: 'auto', maxWidth: 1024, }, -})); - -export default Home; +}); diff --git a/src/app/Login.tsx b/src/app/Login.tsx index 44dae3e1..27e8d870 100644 --- a/src/app/Login.tsx +++ b/src/app/Login.tsx @@ -26,9 +26,10 @@ import CardActions from '@mui/material/CardActions'; import CardContent from '@mui/material/CardContent'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import TextField from '@mui/material/TextField'; +import TextField, { TextFieldProps } from '@mui/material/TextField'; import { ModelIcon } from '@system-dynamics/diagram/ModelIcon'; +import { FormEvent, useState } from 'react'; type EmailLoginStates = 'showEmail' | 'showPassword' | 'showSignup' | 'showProviderRedirect' | 'showRecover'; @@ -40,11 +41,6 @@ export interface LoginProps { interface LoginState { emailLoginFlow: EmailLoginStates | undefined; email: string; - emailError: string | undefined; - password: string; - passwordError: string | undefined; - fullName: string; - fullNameError: string | undefined; provider: 'google.com' | 'apple.com' | undefined; } @@ -55,7 +51,12 @@ function appleProvider(): OAuthProvider { return provider; } -export const GoogleIcon: React.FunctionComponent = styled((props) => { +function getInputAndValue(event: FormEvent, elementName: string) { + const input = event.currentTarget.elements.namedItem(elementName) as HTMLInputElement; + return { input, value: input.value }; +} + +export const GoogleIcon = styled((props) => { return ( @@ -65,431 +66,314 @@ export const GoogleIcon: React.FunctionComponent = styled((props) => { fill: white; `); -export const Login = styled( - class Login extends React.Component { - state: LoginState; - - constructor(props: LoginProps) { - super(props); - - this.state = { - emailLoginFlow: undefined, - email: '', - emailError: undefined, - password: '', - passwordError: undefined, - fullName: '', - fullNameError: undefined, - provider: undefined, - }; - } +function LoginInner({ auth, disabled }: LoginProps) { + const [state, setState] = useState({ + emailLoginFlow: undefined, + email: '', + provider: undefined, + }); - appleLoginClick = () => { - const provider = appleProvider(); - // eslint-disable-next-line @typescript-eslint/no-misused-promises - setTimeout(async () => { - await signInWithRedirect(this.props.auth, provider); - }); - }; - googleLoginClick = () => { - const provider = new GoogleAuthProvider(); - provider.addScope('profile'); - // eslint-disable-next-line @typescript-eslint/no-misused-promises - setTimeout(async () => { - await signInWithRedirect(this.props.auth, provider); - }); - }; - emailLoginClick = () => { - this.setState({ emailLoginFlow: 'showEmail' }); - }; - onFullNameChanged = (event: React.ChangeEvent) => { - this.setState({ fullName: event.target.value }); - }; - onPasswordChanged = (event: React.ChangeEvent) => { - this.setState({ password: event.target.value }); - }; - onEmailChanged = (event: React.ChangeEvent) => { - this.setState({ email: event.target.value }); - }; - onEmailCancel = () => { - this.setState({ emailLoginFlow: undefined }); - }; - onSubmitEmail = async () => { - const email = this.state.email.trim(); - if (!email) { - this.setState({ emailError: 'Enter your email address to continue' }); - return; - } + if (disabled) return <>; + + async function appleLoginClick(event: FormEvent) { + event.preventDefault(); + await signInWithRedirect(auth, appleProvider()); + } + + async function googleLoginClick(event: FormEvent) { + event.preventDefault(); + const provider = new GoogleAuthProvider(); + provider.addScope('profile'); + await signInWithRedirect(auth, provider); + } + + function emailLoginClick() { + setState((state) => ({ ...state, emailLoginFlow: 'showEmail' })); + } + + function onEmailCancel() { + setState((state) => ({ ...state, emailLoginFlow: undefined })); + } + + async function onSubmitEmail(event: FormEvent) { + event.preventDefault(); + + const { input, value: email } = getInputAndValue(event, 'email'); - const methods = await fetchSignInMethodsForEmail(this.props.auth, email); - if (methods.includes('password')) { - this.setState({ emailLoginFlow: 'showPassword' }); - } else if (methods.length === 0) { - this.setState({ emailLoginFlow: 'showSignup' }); + const methods = await fetchSignInMethodsForEmail(auth, email); + if (methods.includes('password')) setState((state) => ({ ...state, email, emailLoginFlow: 'showPassword' })); + else if (methods.length === 0) setState((state) => ({ ...state, email, emailLoginFlow: 'showSignup' })); + else { + // we only allow 1 method + const method = methods[0]; + if (method === 'google.com' || method === 'apple.com') { + setState((state) => ({ + ...state, + emailLoginFlow: 'showProviderRedirect', + provider: methods[0] as 'google.com' | 'apple.com', + })); } else { - // we only allow 1 method - const method = methods[0]; - if (method === 'google.com' || method === 'apple.com') { - this.setState({ - emailLoginFlow: 'showProviderRedirect', - provider: methods[0] as 'google.com' | 'apple.com', - }); - } else { - this.setState({ - emailError: 'an unknown error occurred; try a different email address', - }); - } - } - }; - onSubmitRecovery = async () => { - const email = this.state.email.trim(); - if (!email) { - this.setState({ emailError: 'Enter your email address to continue' }); - return; + input.setCustomValidity('An unknown error occurred; try a different email address'); + input.reportValidity(); } + } + } - await sendPasswordResetEmail(this.props.auth, email); - - this.setState({ - emailLoginFlow: 'showPassword', - password: '', - passwordError: undefined, - }); - }; - onSubmitNewUser = async () => { - const email = this.state.email.trim(); - if (!email) { - this.setState({ emailError: 'Enter your email address to continue' }); - return; - } + async function onSubmitRecovery(event: FormEvent) { + event.preventDefault(); - const fullName = this.state.fullName.trim(); - if (!fullName) { - this.setState({ emailError: 'Enter your email address to continue' }); - return; - } + const { value: email } = getInputAndValue(event, 'email'); - const password = this.state.password.trim(); - if (!password) { - this.setState({ passwordError: 'Enter your email address to continue' }); - return; - } + await sendPasswordResetEmail(auth, email); - try { - const userCred = await createUserWithEmailAndPassword(this.props.auth, email, password); - await updateProfile(userCred.user, { displayName: fullName }); - } catch (err) { - console.log(err); - if (err instanceof Error) { - this.setState({ passwordError: err.message }); - } else { - this.setState({ passwordError: 'something unknown went wrong' }); - } - } - }; - onNullSubmit = (event: React.FormEvent): boolean => { - event.preventDefault(); - return false; - }; - onEmailHelp = () => { - this.setState({ emailLoginFlow: 'showRecover' }); - }; - onEmailLogin = async () => { - const email = this.state.email.trim(); - if (!email) { - this.setState({ emailError: 'Enter your email address to continue' }); - return; - } + setState((state) => ({ ...state, emailLoginFlow: 'showPassword' })); + } - const password = this.state.password.trim(); - if (!password) { - this.setState({ passwordError: 'Enter your email address to continue' }); - return; - } + async function onSubmitNewUser(event: FormEvent) { + event.preventDefault(); + + const { value: email } = getInputAndValue(event, 'email'); + const { value: fullName } = getInputAndValue(event, 'fullName'); + const { input: passwordInput, value: password } = getInputAndValue(event, 'password'); - try { - await signInWithEmailAndPassword(this.props.auth, email, password); - } catch (err) { - console.log(err); - if (err instanceof Error) { - this.setState({ passwordError: err.message }); - } + try { + const userCred = await createUserWithEmailAndPassword(auth, email, password); + await updateProfile(userCred.user, { displayName: fullName }); + } catch (err) { + console.error(err); + if (err instanceof Error) { + passwordInput.setCustomValidity(err.message); + } else { + passwordInput.setCustomValidity('Something unknown went wrong'); } - }; - render() { - const { className } = this.props; - const disabledClass = this.props.disabled ? 'simlin-login-disabled' : 'simlin-login-inner-inner'; - - let loginUI: JSX.Element | undefined = undefined; - if (!this.props.disabled) { - switch (this.state.emailLoginFlow) { - case 'showEmail': - loginUI = ( - -
- - - Sign in with email - - - - - - {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} - - -
-
- ); - break; - case 'showPassword': - loginUI = ( - -
- - - Sign in - - - - - - - - Trouble signing in? - - - {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} - - -
-
- ); - break; - case 'showSignup': - loginUI = ( - -
- - - Create account - - - - - - - - {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} - - -
-
- ); - break; - case 'showProviderRedirect': - const provider = this.state.provider === 'google.com' ? 'Google' : 'Apple'; - loginUI = ( - -
- - - Sign in - you already have an account - - - You’ve already used {provider} to sign up with {this.state.email}. Sign in with {provider}{' '} - to continue. - - - - - -
-
- ); - break; - case 'showRecover': - loginUI = ( - -
- - - Recover password - - - Get instructions sent to this email that explain how to reset your password - - - - - - {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} - - -
-
- ); - break; - default: - loginUI = ( -
- -
- -
- -
-
- ); - } + passwordInput.reportValidity(); + passwordInput.setCustomValidity(''); + } + } + + function onEmailHelp() { + setState((state) => ({ ...state, emailLoginFlow: 'showRecover' })); + } + + async function onEmailLogin(event: FormEvent) { + event.preventDefault(); + + const { value: email } = getInputAndValue(event, 'email'); + const { input: passwordInput, value: password } = getInputAndValue(event, 'password'); + + try { + await signInWithEmailAndPassword(auth, email, password); + } catch (err) { + console.log(err); + if (err instanceof Error) { + passwordInput.setCustomValidity(err.message); + } else { + passwordInput.setCustomValidity('Something unknown went wrong'); } + passwordInput.reportValidity(); + passwordInput.setCustomValidity(''); + } + } + + function EmailField(props: TextFieldProps) { + return ( + + ); + } + + function PasswordField(props: TextFieldProps) { + return ( + + ); + } - return ( -
-
-
- -
-
{loginUI}
-
+ if (state.emailLoginFlow === 'showEmail') + return ( + +
+ + + Sign in with email + + + + + + + +
+
+ ); + else if (state.emailLoginFlow === 'showPassword') + return ( + +
+ + + Sign in + + + + + + + + Trouble signing in? + + + + +
+
+ ); + else if (state.emailLoginFlow === 'showSignup') + return ( + +
+ + + Create account + + + + + + + + + +
+
+ ); + else if (state.emailLoginFlow === 'showProviderRedirect') + return ( + +
+ + + Sign in - you already have an account + + + You’ve already used {state.provider} to sign up with {state.email}. Sign in with {state.provider}{' '} + to continue. + + + + + +
+
+ ); + else if (state.emailLoginFlow === 'showRecover') + return ( + +
+ + + Recover password + + + Get instructions sent to this email that explain how to reset your password + + + + + + + +
+
+ ); + else + return ( +
+ +
+ +
+ +
+
+ ); +} + +function LoginOuter({ className, auth, disabled }: LoginProps & { className?: string }) { + const disabledClass = disabled ? 'simlin-login-disabled' : 'simlin-login-inner-inner'; + + return ( +
+
+
+ +
+
+
- ); - } - }, -)(({ theme }) => ({ +
+
+ ); +} + +export const Login = styled(LoginOuter)(({ theme }) => ({ '&.simlin-login-outer': { display: 'table', position: 'absolute',