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:
Brent Clark 2022-01-30 11:14:28 -06:00 committed by GitHub
parent 4bb13677c8
commit 513fcb0908
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 21467 additions and 161 deletions

21225
webclient/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@
"@material-ui/core": "^4.12.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/styles": "^4.11.4",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"crypto-js": "^4.1.1",
"dexie": "^3.0.3",
"final-form": "^4.20.4",
@ -57,6 +59,11 @@
"last 1 safari version"
]
},
"jest": {
"moduleNameMapper": {
"\\.(css|less)$": "identity-obj-proxy"
}
},
"devDependencies": {
"@types/jest": "27.0.2",
"@types/jquery": "^3.5.8",
@ -76,6 +83,7 @@
"@typescript-eslint/parser": "^5.3.1",
"cross-env": "^7.0.3",
"eslint": "^7.32.0",
"identity-obj-proxy": "^3.0.0",
"run-script-os": "^1.1.6"
}
}

View file

@ -1,23 +1,27 @@
// eslint-disable-next-line
import React from "react";
import { Field } from 'redux-form'
import React from 'react';
import { Field } from 'react-final-form'
import Button from '@material-ui/core/Button';
import { InputField } from 'components';
import './InputAction.css';
const InputAction = ({ action, label, name }) => (
const InputAction = ({ action, label, name, validate, disabled }) => (
<div className="input-action">
<div className="input-action__item">
<Field label={label} name={name} component={InputField} />
<Field label={label} name={name} component={InputField} validate={validate} />
</div>
<div className="input-action__submit">
<Button color="primary" variant="contained" type="submit">
<Button color="primary" variant="contained" type="submit" disabled={disabled}>
{action}
</Button>
</div>
</div>
);
InputAction.defaultProps = {
disabled: false,
validate: () => true,
}
export default InputAction;

View file

@ -1,19 +1,16 @@
// eslint-disable-next-line
import React from "react";
import { connect } from 'react-redux';
import { Form, reduxForm } from 'redux-form'
import React from 'react';
import { Form } from 'react-final-form'
import { InputAction } from 'components';
import { FormKey } from 'types';
const AddToBuddies = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Buddies" name="userName" />
const AddToBuddies = ({ onSubmit }) => (
<Form onSubmit={values => onSubmit(values)}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Buddies" name="userName" />
</form>
)}
</Form>
);
const propsMap = {
form: FormKey.ADD_TO_BUDDIES
};
export default connect()(reduxForm(propsMap)(AddToBuddies));
export default AddToBuddies;

View file

@ -1,19 +1,16 @@
// eslint-disable-next-line
import React from "react";
import { connect } from 'react-redux';
import { Form, reduxForm } from 'redux-form'
import React from 'react';
import { Form } from 'react-final-form'
import { InputAction } from 'components';
import { FormKey } from 'types';
const AddToIgnore = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Ignore" name="userName" />
const AddToIgnore = ({ onSubmit }) => (
<Form onSubmit={values => onSubmit(values)}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<InputAction action="Add" label="Add to Ignore" name="userName" />
</form>
)}
</Form>
);
const propsMap = {
form: FormKey.ADD_TO_IGNORE,
};
export default connect()(reduxForm(propsMap)(AddToIgnore));
export default AddToIgnore;

View file

@ -11,7 +11,7 @@ import Typography from '@material-ui/core/Typography';
import { AuthenticationService } from 'api';
import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog } from 'dialogs';
import { LoginForm } from 'forms';
import { useReduxEffect } from 'hooks';
import { useReduxEffect, useFireOnce } from 'hooks';
import { Images } from 'images';
import { HostDTO } from 'services';
import { RouteEnum, WebSocketConnectOptions, getHostPort } from 'types';
@ -77,15 +77,6 @@ const Login = ({ state, description }: LoginProps) => {
closeResetPasswordDialog();
}, 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 = () => {
return !isConnected && description?.length;
};
@ -121,6 +112,19 @@ const Login = ({ state, description }: LoginProps) => {
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 }) => {
HostDTO.get(selectedHost.id).then(hostDTO => {
hostDTO.remember = remember;
@ -208,7 +212,11 @@ const Login = ({ state, description }: LoginProps) => {
<Typography variant="h1">Login</Typography>
<Typography variant="subtitle1">A cross-platform virtual tabletop for multiplayer card games.</Typography>
<div className="login-form">
<LoginForm onSubmit={onSubmitLogin} onResetPassword={openRequestPasswordResetDialog} />
<LoginForm
onSubmit={handleLogin}
onResetPassword={openRequestPasswordResetDialog}
disableSubmitButton={submitButtonDisabled}
/>
</div>
{

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line
import React, { Component } from "react";
import React from 'react';
import { connect } from 'react-redux';
import { withRouter, generatePath } from 'react-router-dom';
@ -8,7 +7,7 @@ import Paper from '@material-ui/core/Paper';
import { RoomsService } from 'api';
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 OpenGames from './OpenGames';
@ -18,86 +17,73 @@ import SayMessage from './SayMessage';
import './Room.css';
// @TODO (3)
class Room extends Component<any> {
componentDidUpdate() {
const { joined, match, history } = this.props;
let { roomId } = match.params;
function Room(props) {
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)) {
history.push(generatePath(RouteEnum.SERVER));
}
if (!joined.find(({ roomId: id }) => id === roomId)) {
history.push(generatePath(RouteEnum.SERVER));
}
constructor(props) {
super(props);
this.handleRoomSay = this.handleRoomSay.bind(this);
}
handleRoomSay({ message }) {
function handleRoomSay({ message }) {
if (message) {
const { roomId } = this.props.match.params;
RoomsService.roomSay(roomId, message);
}
}
render() {
const { match, rooms } = this.props;
const { roomId } = match.params;
const room = rooms[roomId];
const room = rooms[roomId];
const messages = this.props.messages[roomId];
const users = room.userList;
const roomMessages = messages[roomId];
const users = room.userList;
return (
<div className="room-view">
<AuthGuard />
return (
<div className="room-view">
<AuthGuard />
<div className="room-view__main">
<ThreePaneLayout
fixedHeight
<div className="room-view__main">
<ThreePaneLayout
fixedHeight
top={(
<Paper className="room-view__games overflow-scroll">
<OpenGames room={room} />
top={(
<Paper className="room-view__games overflow-scroll">
<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 className="room-view__messages-sayMessage">
<SayMessage onSubmit={handleRoomSay} />
</Paper>
</div>
)}
bottom={(
<div className="room-view__messages">
<Paper className="room-view__messages-content overflow-scroll">
<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">
side={(
<Paper className="room-view__side overflow-scroll">
<div className="room-view__side-label">
Users in this room: {users.length}
</div>
<VirtualList
className="room-view__side-list"
itemKey={(index, data) => users[index].name }
items={ users.map(user => (
<ListItem button className="room-view__side-list__item">
<UserDisplay user={user} />
</ListItem>
)) }
/>
</Paper>
)}
/>
</div>
</div>
<VirtualList
className="room-view__side-list"
itemKey={(index, data) => users[index].name }
items={ users.map(user => (
<ListItem button className="room-view__side-list__item">
<UserDisplay user={user} />
</ListItem>
)) }
/>
</Paper>
)}
/>
</div>
);
}
</div>
);
}
interface RoomProps {

View file

@ -1,18 +1,24 @@
// eslint-disable-next-line
import React from "react";
import { connect } from 'react-redux';
import { Form, reduxForm } from 'redux-form'
import React from 'react';
import { Form } from 'react-final-form'
import { InputAction } from 'components';
const SayMessage = ({ handleSubmit }) => (
<Form onSubmit={handleSubmit}>
<InputAction action="Send" label="Chat" name="message" />
</Form>
);
const required = (value) => (value ? undefined : 'Required');
const propsMap = {
form: 'sayMessage'
};
const SayMessage = (props) => {
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;

View file

@ -1,7 +1,5 @@
// eslint-disable-next-line
import React, { Component, useCallback, useEffect, useState, useRef } from 'react';
import { connect } from 'react-redux';
import { Form, Field, useField } from 'react-final-form';
import React, { useEffect, useState, useCallback } from 'react';
import { Form, Field } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
import Button from '@material-ui/core/Button';
@ -10,14 +8,14 @@ import { AuthenticationService } from 'api';
import { CheckboxField, InputField, KnownHosts } from 'components';
import { useAutoConnect } from 'hooks';
import { HostDTO, SettingDTO } from 'services';
import { FormKey, APP_USER } from 'types';
import { APP_USER } from 'types';
import './LoginForm.css';
const PASSWORD_LABEL = 'Password';
const STORED_PASSWORD_LABEL = '* SAVED *';
const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
const LoginForm = ({ onSubmit, disableSubmitButton, onResetPassword }: LoginFormProps) => {
const [host, setHost] = useState(null);
const [passwordLabel, setPasswordLabel] = useState(PASSWORD_LABEL);
const [autoConnect, setAutoConnect] = useAutoConnect();
@ -43,6 +41,8 @@ const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
setPasswordLabel(useStoredLabel ? STORED_PASSWORD_LABEL : PASSWORD_LABEL);
};
return (
<Form onSubmit={onSubmit} validate={validate}>
{({ handleSubmit, form }) => {
@ -140,7 +140,13 @@ const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
<OnChange name="autoConnect">{onAutoConnectChange}</OnChange>
</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
</Button>
</form>
@ -152,6 +158,7 @@ const LoginForm = ({ onSubmit, onResetPassword }: LoginFormProps) => {
interface LoginFormProps {
onSubmit: any;
disableSubmitButton: boolean,
onResetPassword: any;
}

View file

@ -1,3 +1,4 @@
export * from './useDebounce';
export * from './useAutoConnect';
export * from './useFireOnce';
export * from './useDebounce';
export * from './useReduxEffect';

View file

@ -0,0 +1 @@
export * from './useFireOnce'

View 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 }
);
});
});

View 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]
}

View file

@ -1,5 +1,8 @@
import protobuf from 'protobufjs';
// ensure jest-dom is always available during testing to cut down on boilerplate
import '@testing-library/jest-dom';
class MockProtobufRoot {
load() {}
}

View file

@ -13,6 +13,7 @@ export {
export * from 'store/server/server.interfaces';
export {
Types as RoomsTypes,
Selectors as RoomsSelectors,
Dispatch as RoomsDispatch } from 'store/rooms';

View file

@ -57,7 +57,9 @@ export class WebClient {
this.handleStatusChange(status);
});
console.log(this);
if (process.env.NODE_ENV !== 'test') {
console.log(this);
}
}
public connect(options: WebSocketConnectOptions) {