Skip to content

Latest commit

 

History

History
845 lines (678 loc) · 20.5 KB

readme.md

File metadata and controls

845 lines (678 loc) · 20.5 KB

15 Login form

In this sample we are going to implement a basic login page, that will redirect the user to another page whenever the login has completed successfully.

We will attempt to create a realistic layout, in order to keep simplicity we will break it into subcomponents and perform some refactor in order to make the solution more maintenable.

We will take a startup point sample 14 ReactRouter:

Summary steps:

  • Let's rename pageA to LoginPage.
  • Let's create a 'Pages' subfolder and reorganize pages.
  • Let's build the layout for this page.
  • Let's add navigation on login button clicked.
  • Let's add login validation fake api.
  • Let's connect it into the login button logic.
  • Let's do a bit of refactor and clean up extracting functionality to reusable components.
  • Let's add some form validation (mandatory fields, minlength).

Prerequisites

Install Node.js and npm (v6.6.0) if they are not already installed on your computer.

Verify that you are running at least node v6.x.x and npm 3.x.x by running node -v and npm -v in a terminal/console window. Older versions may produce errors.

Steps to build it

  • Copy the content from 14 React Router and execute npm install.

  • Let's rename pageA.tsx to loginPage.tsx.

  • Let's update as well the name of the component.

./src/loginPage.tsx

import * as React from "react"
import { Link } from 'react-router-dom';

export const LoginPage = () => {
  return (
    <div>
      <h2> Hello from page A</h2>
      <br/>
      <Link to="/pageB">Navigate to Page B</Link>
    </div>
  )
}
  • Now it's time to reorganize the pages structure. Let's create a subfolders called pages

  • Under that subfolder let's create two more subfolders login and b

  • Let's place the pages under that subfolders: pages/login/loginPage.tsx and _pages/b/pageB.

.
└── src/
    │
    ├── model/
    └── pages/
        ├── login/
        │   └── loginPage.tsx
        └── b/
            └── pageB.tsx

  • In some cases this pages will contain more secondary files, let's create a simple index.tsx file for each of this pages.

  • Under pages/login/index.ts.

./src/pages/login/index.ts

export {LoginPage} from './loginPage';
  • Under pages/b/index.ts

./src/pages/b/index.ts

export {PageB} from './pageB';
  • The structure look like this:
.
└── src/
    │
    └── pages/
        ├── login/
        │   ├── index.ts
        │   └── loginPage.tsx
        └── b/
            ├── index.ts
            └── pageB.tsx
  • Let's update main.tsx (routes, names and add a redirect from root to login page).

./src/main.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { HashRouter, Switch, Route } from 'react-router-dom';
- import { PageA } from './pageA';
- import { PageB } from './pageB';
+ import { LoginPage } from './pages/login';
+ import { PageB } from './pages/b';

ReactDOM.render(
   <HashRouter>
     <Switch>
-       <Route exact={true} path="/" component={PageA} />
+       <Route exact={true} path="/" component={LoginPage} />
       <Route path="/pageB" component={PageB} />
     </Switch>
   </HashRouter>
  ,
  document.getElementById('root')
);
  • Let's update as well the navigation from pageB to loginPage, pageB.tsx.

./src/pages/b/pageB.tsx

import * as React from "react"
import { Link } from 'react-router-dom';

export const PageB = () => {
  return (
    <div>
      <h2>Hello from page B</h2>
      <br />
-      <Link to="/">Navigate to Page A</Link>      
+      <Link to="/">Navigate to Login</Link>
    </div>
  )
}
  • Let's make a quick test and check that everyting is still working fine.
npm start
  • Time to remove 'Sample app' text from the main html.

./src/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
  </head>
  <body>
    <div class="well">
-      <h1>Sample app</h1>
      <div id="root"></div>
    </div>
  </body>
</html>
  • Let's build a proper login layout, loginPage.tsx, we will base it in the following layoutbut we will break it down into subcomponents.

  • To build a nice layout, we will install @material-ui/core

npm install @material-ui/core @material-ui/icons --save-dev
  • Now we could create a login form it could look somethin like:

./src/pages/loginPage.tsx

import * as React from "react"
import { Link } from 'react-router-dom';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { withStyles, createStyles, WithStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import TextField from "@material-ui/core/TextField";
import Button from "@material-ui/core/Button";
import { FormHelperText } from "@material-ui/core";

// https://material-ui.com/guides/typescript/
const styles = theme => createStyles({
  card: {
    maxWidth: 400,
    margin: '0 auto',
  },
});

interface Props extends RouteComponentProps, WithStyles<typeof styles> {
}

const LoginPageInner = (props : Props) => {
  const { classes } = props;

  return (
    <Card className={classes.card}>
      <CardHeader title="Login" />
      <CardContent>
        <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
          <TextField
            label="Name"
            margin="normal"
          />
          <TextField
            label="Password"
            type="password"
            margin="normal"
          />
          <Button variant="contained" color="primary">
            Login
          </Button>
        </div>
      </CardContent>
    </Card>
  )
}

export const LoginPage = withStyles(styles)(withRouter<Props>((LoginPageInner)));
  • This can be ok, but if we take a deeper look to this component, we could break down into two, one is the card itself the other the form dialog, so it should finally look like:

** Proposal **

    <Card className={classes.card}>
      <CardHeader title="Login" />
      <CardContent>
        <LoginForm />
      </CardContent>
    </Card>
  • Let's create the loginformcomponent:

./src/pages/login/loginForm.tsx

import * as React from "react"
import TextField from "@material-ui/core/TextField";
import Button from "@material-ui/core/Button";

export const LoginForm = (props) => {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
      <TextField
        label="Name"
        margin="normal"
      />
      <TextField
        label="Password"
        type="password"
        margin="normal"
      />
      <Button variant="contained" color="primary">
        Login
      </Button>
    </div>
  )
}
  • And let's update the loginPage.tsx

./src/pages/loginPage.tsx

import * as React from "react"
import { Link } from 'react-router-dom';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { withStyles, createStyles, WithStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import TextField from "@material-ui/core/TextField";
import Button from "@material-ui/core/Button";
import { FormHelperText } from "@material-ui/core";
+ import { LoginForm } from './loginForm';

// https://material-ui.com/guides/typescript/
const styles = theme => createStyles({
  card: {
    maxWidth: 400,
    margin: '0 auto',
  },
});

interface Props extends RouteComponentProps, WithStyles<typeof styles> {
}

const LoginPageInner = (props : Props) => {
  const { classes } = props;

  return (
    <Card className={classes.card}>
      <CardHeader title="Login" />
      <CardContent>
+        <LoginForm/>
-        <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
-          <TextField
-            label="Name"
-            margin="normal"
-          />
-          <TextField
-            label="Password"
-            type="password"
-            margin="normal"
-          />
-          <Button variant="contained" color="primary">
-            Login
-          </Button>
-        </div>
      </CardContent>
    </Card>
  )
}

export const LoginPage = withStyles(styles)(withRouter<Props>((LoginPageInner)));
  • Let's give a try and check how is it looking.
npm start
  • Le'ts add the navigation on button clicked, we will do it in two steps.

  • First we will expose a method to do that in the loginPage.

./src/pages/login/loginPage.tsx

// ...

// https://material-ui.com/guides/typescript/
const styles = theme => createStyles({
  card: {
    maxWidth: 400,
    margin: '0 auto',
  },
});

interface Props extends RouteComponentProps, WithStyles<typeof styles> {
}

const LoginPageInner = (props) => {
  const { classes } = props;

+   const onLogin = () => {
+      props.history.push('/pageB');  
+   }

  return (
    <Card className={classes.card}>
      <CardHeader title="Login" />
      <CardContent>
-        <LoginForm/>
+        <LoginForm onLogin={onLogin}/>
      </CardContent>
    </Card>
  )
}

- export const LoginPage = withStyles(styles)(LoginPageInner);
+ export const LoginPage = withStyles(styles)(withRouter<Props>((LoginPageInner)));
  • Let's add the navigation on button clicked (later on we will check for user and pwd) form.tsx. In order to do this we will use react-router 4 "withRouter" HoC (High order component).

./src/pages/login/LoginForm.tsx

import * as React from "react"

interface Props {
+  onLogin : () => void;
}

+export const LoginForm = (props : Props) => {
- export const LoginForm = () => {  
+   const { onLogin } = this.props;
  
  return (
    <div className="panel-body">
      <form accept-charset="UTF-8" role="form">
        <fieldset>
          <div className="form-group">
      		  <input className="form-control" placeholder="E-mail" name="email" type="text"/>
      		</div>
          <div className="form-group">
            <input className="form-control" placeholder="Password" name="password" type="password" value=""/>
          </div>
-          <input className="btn btn-lg btn-success btn-block" type="submit" value="Login"
-          />
-          <Button variant="contained" color="primary">
+          <Button variant="contained" color="primary" onClick={onLogin}>
            Login
          </Button>
        </fieldset>
      </form>
    </div>
  );
- }
+})
  • Let's give a quick try.
npm start

Ok, we can navigate whenever we click on the login page.

  • Let's keep on progressing, now is time to collect the username and password info, and check if password is valid or not.

  • Let's define an entity for the loginInfo let's create the following path and file

src/model/login.ts

export interface LoginEntity {
  login : string;
  password : string;
}

export const createEmptyLogin = () : LoginEntity => ({
  login: '',
  password: '',
});
  • Let's add login validation fake restApi: create a folder src/api. and a file called login.ts

./src/api/login.ts

import {LoginEntity} from '../model/login';

// Just a fake loginAPI
export const isValidLogin = (loginInfo : LoginEntity) : boolean =>
  (loginInfo.login === 'admin' && loginInfo.password === 'test');
  • How it should look
.
└── src/
    │
    ├── api/
    │   └── login.ts
    ├── model/
    │   └── login.ts
    └── pages/
        ├── login/
        │   ├── form.tsx
        │   ├── header.tsx
        │   ├── index.ts
        │   └── loginPage.tsx
        └── b/
            ├── index.ts
            └── pageB.tsx
  • Let's add the api integration, plus navigation on login succeeded:

  • First let's create a login state and add the api integration.

./src/pages/login/loginPage.tsx

import * as React from "react"
import { withStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import { LoginForm } from './loginForm';
import { withRouter } from 'react-router-dom';
import {History} from 'history';
+ import { LoginEntity, createEmptyLogin } from '../../model/login';
+ import { isValidLogin } from '../../api/login';

const styles = theme => ({
  card: {
    maxWidth: 400,
    margin: '0 auto',
  },
});

+ interface State {
+   loginInfo: LoginEntity;
+ }

interface Props extends RouteComponentProps, WithStyles<typeof styles> {
}

- const LoginPageInner = (props) => {
+   class LoginPageInner extends React.Component<Props, State> {
-  const { classes, history } = props;

+  constructor(props) {
+    super(props);
+
+    this.state = { loginInfo: createEmptyLogin() }
+  }

-  const onLogin = () => {
+  onLogin = () => {  
+    if (isValidLogin(this.state.loginInfo)) {
+        this.props.history.push('/pageB');
-        history.push('/pageB');
+    }
  }

+  public render() {
+   const { classes } = this.props;

  return (
    <Card className={classes.card}>
      <CardHeader title="Login" />
      <CardContent>
-        <LoginForm onLogin={onLogin} />      
+        <LoginForm onLogin={this.onLogin} />
      </CardContent>
    </Card>
  )
+  }
}

export const LoginPage = withStyles(styles)(withRouter<Props>((LoginPageInner)));
  • Now let's read the data from the textfields components (user and password).

./src/pages/login/loginPage.tsx

class LoginPageInner extends React.Component<Props, State> {

  onLogin = () => {
    if (isValidLogin(this.state.loginInfo)) {
      this.props.history.push('/pageB');
    }
  }

+  onUpdateLoginField = (name, value) => {
+    this.setState({loginInfo: {
+      ...this.state.loginInfo,
+      [name]: value,
+    }})
+  }

  render() {
    const { classes } = this.props;

    return (
      <Card className={classes.card}>
        <CardHeader title="Login" />
        <CardContent>
          <LoginForm 
            onLogin={this.onLogin} 
+            onUpdateField={this.onUpdateLoginField}
+            loginInfo={this.state.loginInfo}
          />
        </CardContent>
      </Card>
    )

  }
}

./src/pages/login/loginForm.tsx

import * as React from "react"
import TextField from "@material-ui/core/TextField";
import Button from "@material-ui/core/Button";
+ import { LoginEntity } from "../../model/login";

interface Props {  
  onLogin : () => void;
+  onUpdateField: (string, any) => void;
+  loginInfo : LoginEntity;
}

export const LoginForm = (props : Props) => {
-  const { onLogin } = props;
+  const { onLogin, onUpdateField, loginInfo } = props;

+  // TODO: Enhacement move this outside the stateless component discuss why is a good idea
+  const onTexFieldChange = (fieldId) => (e) => {
+    onUpdateField(fieldId, e.target.value);
+  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
      <TextField
        label="Name"
        margin="normal"
+        value={loginInfo.login}
+        onChange={onTexFieldChange('login')}
      />
      <TextField
        label="Password"
        type="password"
        margin="normal"
+        value={loginInfo.password}
+        onChange={onTexFieldChange('password')}
      />
      <Button variant="contained" color="primary" onClick={onLogin}>
        Login
      </Button>
    </div>
  )
}
  • Let's display a notification when the login validation fails.

  • First we will create a simple notification component, base on react material ui snackbar

./src/common/notification.tsx

import * as React from "react"
import Button from '@material-ui/core/Button';
import Snackbar from '@material-ui/core/Snackbar';
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import { withStyles } from "@material-ui/core";

interface Props {
  classes?: any;
  message: string;
  show: boolean;
  onClose: () => void;
}

const styles = theme => ({
  close: {
    padding: theme.spacing.unit / 2,
  },
});

const NotificationComponentInner = (props: Props) => {
  const {classes, message, show, onClose } = props;

  return (
    <Snackbar
      anchorOrigin={{
        vertical: 'bottom',
        horizontal: 'left',
      }}    
      open={show}
      autoHideDuration={3000}
      onClose={onClose}
      ContentProps={{
        'aria-describedby': 'message-id',
      }}
      message={<span id="message-id">{message}</span>}
      action={[
        <IconButton
          key="close"
          aria-label="Close"
          color="inherit"
          className={classes.close}
          onClick={onClose}
        >
          <CloseIcon />
        </IconButton>,
      ]}
      
    />
  )
}

export const NotificationComponent = withStyles(styles)(NotificationComponentInner);
  • Let's expose this common component via an index file.

./src/common/index.tsx

export * from './notification';
  • Now let's instantiate this in our loginPage

./src/pages/login/loginPage.tsx

import * as React from "react"
import { withStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardHeader from '@material-ui/core/CardHeader';
import CardContent from '@material-ui/core/CardContent';
import { LoginForm } from './loginForm';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { History } from 'history';
import { LoginEntity, createEmptyLogin } from '../../model/login';
import { isValidLogin } from '../../api/login';
+ import { NotificationComponent } from '../../common'

const styles = theme => ({
  card: {
    maxWidth: 400,
    margin: '0 auto',
  },
});

interface State {
  loginInfo: LoginEntity;
+   showLoginFailedMsg: boolean;
}


interface Props extends RouteComponentProps {
  classes?: any;
}

class LoginPageInner extends React.Component<Props, State> {

  constructor(props) {
    super(props);

    this.state = { loginInfo: createEmptyLogin(),
+                   showLoginFailedMsg : false,
     }
  }

  onLogin = () => {
    if (isValidLogin(this.state.loginInfo)) {
      this.props.history.push('/pageB');
-    }
+    } else {
+      this.setState({showLoginFailedMsg: true});
+    }
  }

  onUpdateLoginField = (name: string, value) => {
    this.setState({
      loginInfo: {
        ...this.state.loginInfo,
        [name]: value,
      }
    })
  }

  render() {
    const { classes } = this.props;

    return (
+      <>
        <Card className={classes.card}>
          <CardHeader title="Login" />
          <CardContent>
            <LoginForm onLogin={this.onLogin}
              onUpdateField={this.onUpdateLoginField}
              loginInfo={this.state.loginInfo}
            />
          </CardContent>
        </Card>
+        <NotificationComponent 
+          message="Invalid login or password, please type again"
+          show={this.state.showLoginFailedMsg}
+          onClose={() => this.setState({showLoginFailedMsg: false})}
+        />
+      </>
    )

  }
}

export const LoginPage = withStyles(styles)(withRouter<Props>((LoginPageInner)));
  • We are getting some warnings because of Typography new version, let's add a Theme to fix that.

Watchout new typgoraphy and snackbar: mui/material-ui#13144

./src/main.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { HashRouter, Switch, Route } from 'react-router-dom';
+ import { createMuiTheme, MuiThemeProvider } from '@material-ui/core/styles';
import {LoginPage} from './pages/login';
import {PageB} from './pages/b';

+ const theme = createMuiTheme({
+  typography: {
+    useNextVariants: true,
+  },
+ });


ReactDOM.render(
+  <MuiThemeProvider theme={theme}>
    <HashRouter>
      <Switch>
        <Route exact={true} path="/" component={LoginPage} />
        <Route path="/pageB" component={PageB} />
      </Switch>
    </HashRouter>
+    </MuiThemeProvider>
  ,document.getElementById('root')
);
  • We are getting some warning on the snack bar, you can find a fix here:

mui/material-ui#13144

https://codesandbox.io/s/zz6wnqklzm

And form validation? There are several libraries available, one that we had created in lemoncode is lc-form-validation we will create a sample including this lib to validate the login form (required fields)