Webclient: Handle firing an event once (#4499)
* draft: handle firing an event once * lint * Prevent rapid double-click on sending messages * no rest spread on single primative when sibling components exist * clear message instead of using a fireOnce handler. * fix tests * remove unnecessary validate mock
This commit is contained in:
parent
4bb13677c8
commit
513fcb0908
16 changed files with 21467 additions and 161 deletions
21225
webclient/package-lock.json
generated
21225
webclient/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -6,6 +6,8 @@
|
||||||
"@material-ui/core": "^4.12.3",
|
"@material-ui/core": "^4.12.3",
|
||||||
"@material-ui/icons": "^4.11.2",
|
"@material-ui/icons": "^4.11.2",
|
||||||
"@material-ui/styles": "^4.11.4",
|
"@material-ui/styles": "^4.11.4",
|
||||||
|
"@testing-library/jest-dom": "^5.16.1",
|
||||||
|
"@testing-library/react": "^12.1.2",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
"dexie": "^3.0.3",
|
"dexie": "^3.0.3",
|
||||||
"final-form": "^4.20.4",
|
"final-form": "^4.20.4",
|
||||||
|
@ -57,6 +59,11 @@
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"\\.(css|less)$": "identity-obj-proxy"
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "27.0.2",
|
"@types/jest": "27.0.2",
|
||||||
"@types/jquery": "^3.5.8",
|
"@types/jquery": "^3.5.8",
|
||||||
|
@ -76,6 +83,7 @@
|
||||||
"@typescript-eslint/parser": "^5.3.1",
|
"@typescript-eslint/parser": "^5.3.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"run-script-os": "^1.1.6"
|
"run-script-os": "^1.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,27 @@
|
||||||
// eslint-disable-next-line
|
import React from 'react';
|
||||||
import React from "react";
|
import { Field } from 'react-final-form'
|
||||||
import { Field } from 'redux-form'
|
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@material-ui/core/Button';
|
||||||
|
|
||||||
import { InputField } from 'components';
|
import { InputField } from 'components';
|
||||||
|
|
||||||
import './InputAction.css';
|
import './InputAction.css';
|
||||||
|
|
||||||
const InputAction = ({ action, label, name }) => (
|
const InputAction = ({ action, label, name, validate, disabled }) => (
|
||||||
<div className="input-action">
|
<div className="input-action">
|
||||||
<div className="input-action__item">
|
<div className="input-action__item">
|
||||||
<Field label={label} name={name} component={InputField} />
|
<Field label={label} name={name} component={InputField} validate={validate} />
|
||||||
</div>
|
</div>
|
||||||
<div className="input-action__submit">
|
<div className="input-action__submit">
|
||||||
<Button color="primary" variant="contained" type="submit">
|
<Button color="primary" variant="contained" type="submit" disabled={disabled}>
|
||||||
{action}
|
{action}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
InputAction.defaultProps = {
|
||||||
|
disabled: false,
|
||||||
|
validate: () => true,
|
||||||
|
}
|
||||||
|
|
||||||
export default InputAction;
|
export default InputAction;
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
// eslint-disable-next-line
|
import React from 'react';
|
||||||
import React from "react";
|
import { Form } from 'react-final-form'
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Form, reduxForm } from 'redux-form'
|
|
||||||
|
|
||||||
import { InputAction } from 'components';
|
import { InputAction } from 'components';
|
||||||
import { FormKey } from 'types';
|
|
||||||
|
|
||||||
const AddToBuddies = ({ handleSubmit }) => (
|
const AddToBuddies = ({ onSubmit }) => (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={values => onSubmit(values)}>
|
||||||
<InputAction action="Add" label="Add to Buddies" name="userName" />
|
{({ handleSubmit }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<InputAction action="Add" label="Add to Buddies" name="userName" />
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
||||||
const propsMap = {
|
export default AddToBuddies;
|
||||||
form: FormKey.ADD_TO_BUDDIES
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect()(reduxForm(propsMap)(AddToBuddies));
|
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
// eslint-disable-next-line
|
import React from 'react';
|
||||||
import React from "react";
|
import { Form } from 'react-final-form'
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Form, reduxForm } from 'redux-form'
|
|
||||||
|
|
||||||
import { InputAction } from 'components';
|
import { InputAction } from 'components';
|
||||||
import { FormKey } from 'types';
|
|
||||||
|
|
||||||
const AddToIgnore = ({ handleSubmit }) => (
|
const AddToIgnore = ({ onSubmit }) => (
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={values => onSubmit(values)}>
|
||||||
<InputAction action="Add" label="Add to Ignore" name="userName" />
|
{({ handleSubmit }) => (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<InputAction action="Add" label="Add to Ignore" name="userName" />
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
||||||
const propsMap = {
|
export default AddToIgnore;
|
||||||
form: FormKey.ADD_TO_IGNORE,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect()(reduxForm(propsMap)(AddToIgnore));
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Typography from '@material-ui/core/Typography';
|
||||||
import { AuthenticationService } from 'api';
|
import { AuthenticationService } from 'api';
|
||||||
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog } from 'dialogs';
|
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog } from 'dialogs';
|
||||||
import { LoginForm } from 'forms';
|
import { LoginForm } from 'forms';
|
||||||
import { useReduxEffect } from 'hooks';
|
import { useReduxEffect, useFireOnce } from 'hooks';
|
||||||
import { Images } from 'images';
|
import { Images } from 'images';
|
||||||
import { HostDTO } from 'services';
|
import { HostDTO } from 'services';
|
||||||
import { RouteEnum, WebSocketConnectOptions, getHostPort } from 'types';
|
import { RouteEnum, WebSocketConnectOptions, getHostPort } from 'types';
|
||||||
|
@ -77,15 +77,6 @@ const Login = ({ state, description }: LoginProps) => {
|
||||||
closeResetPasswordDialog();
|
closeResetPasswordDialog();
|
||||||
}, ServerTypes.RESET_PASSWORD_SUCCESS, []);
|
}, ServerTypes.RESET_PASSWORD_SUCCESS, []);
|
||||||
|
|
||||||
useReduxEffect(({ options: { hashedPassword } }) => {
|
|
||||||
if (hostIdToRemember) {
|
|
||||||
HostDTO.get(hostIdToRemember).then(host => {
|
|
||||||
host.hashedPassword = hashedPassword;
|
|
||||||
host.save();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, ServerTypes.LOGIN_SUCCESSFUL, [hostIdToRemember]);
|
|
||||||
|
|
||||||
const showDescription = () => {
|
const showDescription = () => {
|
||||||
return !isConnected && description?.length;
|
return !isConnected && description?.length;
|
||||||
};
|
};
|
||||||
|
@ -121,6 +112,19 @@ const Login = ({ state, description }: LoginProps) => {
|
||||||
AuthenticationService.login(options as WebSocketConnectOptions);
|
AuthenticationService.login(options as WebSocketConnectOptions);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin)
|
||||||
|
|
||||||
|
useReduxEffect(({ options: { hashedPassword } }) => {
|
||||||
|
if (hostIdToRemember) {
|
||||||
|
HostDTO.get(hostIdToRemember).then(host => {
|
||||||
|
host.hashedPassword = hashedPassword;
|
||||||
|
host.save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resetSubmitButton()
|
||||||
|
}, ServerTypes.LOGIN_SUCCESSFUL, [hostIdToRemember]);
|
||||||
|
|
||||||
|
|
||||||
const updateHost = ({ selectedHost, userName, hashedPassword, remember }) => {
|
const updateHost = ({ selectedHost, userName, hashedPassword, remember }) => {
|
||||||
HostDTO.get(selectedHost.id).then(hostDTO => {
|
HostDTO.get(selectedHost.id).then(hostDTO => {
|
||||||
hostDTO.remember = remember;
|
hostDTO.remember = remember;
|
||||||
|
@ -208,7 +212,11 @@ const Login = ({ state, description }: LoginProps) => {
|
||||||
<Typography variant="h1">Login</Typography>
|
<Typography variant="h1">Login</Typography>
|
||||||
<Typography variant="subtitle1">A cross-platform virtual tabletop for multiplayer card games.</Typography>
|
<Typography variant="subtitle1">A cross-platform virtual tabletop for multiplayer card games.</Typography>
|
||||||
<div className="login-form">
|
<div className="login-form">
|
||||||
<LoginForm onSubmit={onSubmitLogin} onResetPassword={openRequestPasswordResetDialog} />
|
<LoginForm
|
||||||
|
onSubmit={handleLogin}
|
||||||
|
onResetPassword={openRequestPasswordResetDialog}
|
||||||
|
disableSubmitButton={submitButtonDisabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
// eslint-disable-next-line
|
import React from 'react';
|
||||||
import React, { Component } from "react";
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { withRouter, generatePath } from 'react-router-dom';
|
import { withRouter, generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -8,7 +7,7 @@ import Paper from '@material-ui/core/Paper';
|
||||||
|
|
||||||
import { RoomsService } from 'api';
|
import { RoomsService } from 'api';
|
||||||
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from 'components';
|
import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from 'components';
|
||||||
import { RoomsStateMessages, RoomsStateRooms, JoinedRooms, RoomsSelectors } from 'store';
|
import { RoomsStateMessages, RoomsStateRooms, JoinedRooms, RoomsSelectors, RoomsTypes } from 'store';
|
||||||
import { RouteEnum } from 'types';
|
import { RouteEnum } from 'types';
|
||||||
|
|
||||||
import OpenGames from './OpenGames';
|
import OpenGames from './OpenGames';
|
||||||
|
@ -18,86 +17,73 @@ import SayMessage from './SayMessage';
|
||||||
import './Room.css';
|
import './Room.css';
|
||||||
|
|
||||||
// @TODO (3)
|
// @TODO (3)
|
||||||
class Room extends Component<any> {
|
function Room(props) {
|
||||||
componentDidUpdate() {
|
|
||||||
const { joined, match, history } = this.props;
|
|
||||||
let { roomId } = match.params;
|
|
||||||
|
|
||||||
roomId = parseInt(roomId, 0);
|
const { joined, match, history, rooms, messages } = props;
|
||||||
|
const roomId = parseInt(match.params.roomId, 0);
|
||||||
|
|
||||||
if (!joined.find(({ roomId: id }) => id === roomId)) {
|
if (!joined.find(({ roomId: id }) => id === roomId)) {
|
||||||
history.push(generatePath(RouteEnum.SERVER));
|
history.push(generatePath(RouteEnum.SERVER));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(props) {
|
function handleRoomSay({ message }) {
|
||||||
super(props);
|
|
||||||
this.handleRoomSay = this.handleRoomSay.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRoomSay({ message }) {
|
|
||||||
if (message) {
|
if (message) {
|
||||||
const { roomId } = this.props.match.params;
|
|
||||||
RoomsService.roomSay(roomId, message);
|
RoomsService.roomSay(roomId, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
const room = rooms[roomId];
|
||||||
const { match, rooms } = this.props;
|
|
||||||
const { roomId } = match.params;
|
|
||||||
const room = rooms[roomId];
|
|
||||||
|
|
||||||
const messages = this.props.messages[roomId];
|
const roomMessages = messages[roomId];
|
||||||
const users = room.userList;
|
const users = room.userList;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="room-view">
|
<div className="room-view">
|
||||||
<AuthGuard />
|
<AuthGuard />
|
||||||
|
|
||||||
<div className="room-view__main">
|
<div className="room-view__main">
|
||||||
<ThreePaneLayout
|
<ThreePaneLayout
|
||||||
fixedHeight
|
fixedHeight
|
||||||
|
|
||||||
top={(
|
top={(
|
||||||
<Paper className="room-view__games overflow-scroll">
|
<Paper className="room-view__games overflow-scroll">
|
||||||
<OpenGames room={room} />
|
<OpenGames room={room} />
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
bottom={(
|
||||||
|
<div className="room-view__messages">
|
||||||
|
<Paper className="room-view__messages-content overflow-scroll">
|
||||||
|
<ScrollToBottomOnChanges changes={roomMessages} content={(
|
||||||
|
<Messages messages={roomMessages} />
|
||||||
|
)} />
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
<Paper className="room-view__messages-sayMessage">
|
||||||
|
<SayMessage onSubmit={handleRoomSay} />
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
bottom={(
|
side={(
|
||||||
<div className="room-view__messages">
|
<Paper className="room-view__side overflow-scroll">
|
||||||
<Paper className="room-view__messages-content overflow-scroll">
|
<div className="room-view__side-label">
|
||||||
<ScrollToBottomOnChanges changes={messages} content={(
|
|
||||||
<Messages messages={messages} />
|
|
||||||
)} />
|
|
||||||
</Paper>
|
|
||||||
<Paper className="room-view__messages-sayMessage">
|
|
||||||
<SayMessage onSubmit={this.handleRoomSay} />
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
side={(
|
|
||||||
<Paper className="room-view__side overflow-scroll">
|
|
||||||
<div className="room-view__side-label">
|
|
||||||
Users in this room: {users.length}
|
Users in this room: {users.length}
|
||||||
</div>
|
</div>
|
||||||
<VirtualList
|
<VirtualList
|
||||||
className="room-view__side-list"
|
className="room-view__side-list"
|
||||||
itemKey={(index, data) => users[index].name }
|
itemKey={(index, data) => users[index].name }
|
||||||
items={ users.map(user => (
|
items={ users.map(user => (
|
||||||
<ListItem button className="room-view__side-list__item">
|
<ListItem button className="room-view__side-list__item">
|
||||||
<UserDisplay user={user} />
|
<UserDisplay user={user} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
)) }
|
)) }
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoomProps {
|
interface RoomProps {
|
||||||
|
|
|
@ -1,18 +1,24 @@
|
||||||
// eslint-disable-next-line
|
import React from 'react';
|
||||||
import React from "react";
|
import { Form } from 'react-final-form'
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Form, reduxForm } from 'redux-form'
|
|
||||||
|
|
||||||
import { InputAction } from 'components';
|
import { InputAction } from 'components';
|
||||||
|
|
||||||
const SayMessage = ({ handleSubmit }) => (
|
const required = (value) => (value ? undefined : 'Required');
|
||||||
<Form onSubmit={handleSubmit}>
|
|
||||||
<InputAction action="Send" label="Chat" name="message" />
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
|
|
||||||
const propsMap = {
|
const SayMessage = (props) => {
|
||||||
form: 'sayMessage'
|
const { onSubmit } = props
|
||||||
};
|
return (
|
||||||
|
<Form onSubmit={values => onSubmit(values)}>
|
||||||
|
{({ handleSubmit, form }) => (
|
||||||
|
<form onSubmit={e => {
|
||||||
|
handleSubmit(e)
|
||||||
|
form.restart()
|
||||||
|
}}>
|
||||||
|
<InputAction action="Send" label="Chat" name="message" validate={required}/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default connect()(reduxForm(propsMap)(SayMessage));
|
export default SayMessage;
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
// eslint-disable-next-line
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import React, { Component, useCallback, useEffect, useState, useRef } from 'react';
|
import { Form, Field } from 'react-final-form';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Form, Field, useField } from 'react-final-form';
|
|
||||||
import { OnChange } from 'react-final-form-listeners';
|
import { OnChange } from 'react-final-form-listeners';
|
||||||
|
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@material-ui/core/Button';
|
||||||
|
@ -10,14 +8,14 @@ import { AuthenticationService } from 'api';
|
||||||
import { CheckboxField, InputField, KnownHosts } from 'components';
|
import { CheckboxField, InputField, KnownHosts } from 'components';
|
||||||
import { useAutoConnect } from 'hooks';
|
import { useAutoConnect } from 'hooks';
|
||||||
import { HostDTO, SettingDTO } from 'services';
|
import { HostDTO, SettingDTO } from 'services';
|
||||||
import { FormKey, APP_USER } from 'types';
|
import { APP_USER } from 'types';
|
||||||
|
|
||||||
import './LoginForm.css';
|
import './LoginForm.css';
|
||||||
|
|
||||||
const PASSWORD_LABEL = 'Password';
|
const PASSWORD_LABEL = 'Password';
|
||||||
const STORED_PASSWORD_LABEL = '* SAVED *';
|
const STORED_PASSWORD_LABEL = '* SAVED *';
|
||||||
|
|
||||||
const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
|
const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginFormProps) => {
|
||||||
const [host, setHost] = useState(null);
|
const [host, setHost] = useState(null);
|
||||||
const [passwordLabel, setPasswordLabel] = useState(PASSWORD_LABEL);
|
const [passwordLabel, setPasswordLabel] = useState(PASSWORD_LABEL);
|
||||||
const [autoConnect, setAutoConnect] = useAutoConnect();
|
const [autoConnect, setAutoConnect] = useAutoConnect();
|
||||||
|
@ -43,6 +41,8 @@ const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
|
||||||
setPasswordLabel(useStoredLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL);
|
setPasswordLabel(useStoredLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={onSubmit} validate={validate}>
|
<Form onSubmit={onSubmit} validate={validate}>
|
||||||
{({ handleSubmit, form }) => {
|
{({ handleSubmit, form }) => {
|
||||||
|
@ -140,7 +140,13 @@ const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
|
||||||
<OnChange name="autoConnect">{onAutoConnectChange}</OnChange>
|
<OnChange name="autoConnect">{onAutoConnectChange}</OnChange>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className='loginForm-submit rounded tall' color='primary' variant='contained' type='submit'>
|
<Button
|
||||||
|
className='loginForm-submit rounded tall'
|
||||||
|
color='primary'
|
||||||
|
variant='contained'
|
||||||
|
type='submit'
|
||||||
|
disabled={disableSubmitButton}
|
||||||
|
>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -152,6 +158,7 @@ const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSubmit: any;
|
onSubmit: any;
|
||||||
|
disableSubmitButton: boolean,
|
||||||
onResetPassword: any;
|
onResetPassword: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './useDebounce';
|
|
||||||
export * from './useAutoConnect';
|
export * from './useAutoConnect';
|
||||||
|
export * from './useFireOnce';
|
||||||
|
export * from './useDebounce';
|
||||||
export * from './useReduxEffect';
|
export * from './useReduxEffect';
|
||||||
|
|
1
webclient/src/hooks/useFireOnce/index.ts
Normal file
1
webclient/src/hooks/useFireOnce/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './useFireOnce'
|
103
webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx
Normal file
103
webclient/src/hooks/useFireOnce/useFireOnce.spec.tsx
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
fireEvent,
|
||||||
|
getByRole,
|
||||||
|
waitFor,
|
||||||
|
act
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { useFireOnce } from './useFireOnce';
|
||||||
|
|
||||||
|
describe('useFireOnce hook', () => {
|
||||||
|
test('it only fires once when button is clicked twice', async () => {
|
||||||
|
// Mock a promise with a delay
|
||||||
|
const onClickWithPromise = jest.fn((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(true);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function Button(props) {
|
||||||
|
const { children, onClick } = props
|
||||||
|
const [buttonIsDisabled, setButtonIsDisabled, handleClickOnce] = useFireOnce(onClick)
|
||||||
|
return <button onClick={handleClickOnce} disabled={buttonIsDisabled}>{children}</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
// render the button
|
||||||
|
const { getByRole } = render(
|
||||||
|
<Button onClick={onClickWithPromise}>Click Me!</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
//Grab the button from the DOM and confirm it initialized in an enabled state
|
||||||
|
const button = getByRole('button', { name: 'Click Me!' });
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
|
||||||
|
// Simulate two click events in a row
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Confirm that it's disabled
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm it became enabled after the timeout and that the click event was only fired once
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(onClickWithPromise).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
{ timeout: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it only fires once when form is submitted twice', async () => {
|
||||||
|
// Mock a promise with a delay
|
||||||
|
const onClickWithPromise = jest.fn((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(true);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function Form(props) {
|
||||||
|
const { onSubmit } = props
|
||||||
|
const [buttonIsDisabled, setButtonIsDisabled, handleSubmitOnce] = useFireOnce(onSubmit)
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmitOnce}>
|
||||||
|
<input type="text" defaultValue="Hell World" name="thing-to-say" />
|
||||||
|
<button disabled={buttonIsDisabled}>Click Me!</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// render the form
|
||||||
|
const { getByRole } = render(
|
||||||
|
<Form onSubmit={onClickWithPromise} />
|
||||||
|
);
|
||||||
|
|
||||||
|
//Grab the button from the DOM and confirm it initialized in an enabled state
|
||||||
|
const button = getByRole('button', { name: 'Click Me!' });
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
|
||||||
|
// Simulate two click events in a row
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
// Confirm that it's disabled
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm it became enabled after the timeout and that the click event was only fired once
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(onClickWithPromise).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
{ timeout: 100 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
17
webclient/src/hooks/useFireOnce/useFireOnce.ts
Normal file
17
webclient/src/hooks/useFireOnce/useFireOnce.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useReduxEffect } from 'hooks';
|
||||||
|
import { ServerTypes } from 'store';
|
||||||
|
|
||||||
|
type UseFireOnceType = (...args: any) => any;
|
||||||
|
|
||||||
|
export function useFireOnce<T extends UseFireOnceType>(fn: T): [boolean, any, any] {
|
||||||
|
const [actionIsInFlight, setActionIsInFlight] = useState(false)
|
||||||
|
const handleFireOnce = useCallback((args) => {
|
||||||
|
setActionIsInFlight(true);
|
||||||
|
fn(args);
|
||||||
|
}, [])
|
||||||
|
function resetInFlightStatus() {
|
||||||
|
setActionIsInFlight(false);
|
||||||
|
}
|
||||||
|
return [actionIsInFlight, resetInFlightStatus, handleFireOnce]
|
||||||
|
}
|
|
@ -1,5 +1,8 @@
|
||||||
import protobuf from 'protobufjs';
|
import protobuf from 'protobufjs';
|
||||||
|
|
||||||
|
// ensure jest-dom is always available during testing to cut down on boilerplate
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
class MockProtobufRoot {
|
class MockProtobufRoot {
|
||||||
load() {}
|
load() {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ export {
|
||||||
export * from 'store/server/server.interfaces';
|
export * from 'store/server/server.interfaces';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
Types as RoomsTypes,
|
||||||
Selectors as RoomsSelectors,
|
Selectors as RoomsSelectors,
|
||||||
Dispatch as RoomsDispatch } from 'store/rooms';
|
Dispatch as RoomsDispatch } from 'store/rooms';
|
||||||
|
|
||||||
|
|
|
@ -57,7 +57,9 @@ export class WebClient {
|
||||||
this.handleStatusChange(status);
|
this.handleStatusChange(status);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(this);
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
console.log(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public connect(options: WebSocketConnectOptions) {
|
public connect(options: WebSocketConnectOptions) {
|
||||||
|
|
Loading…
Reference in a new issue