Skip to content
Snippets Groups Projects
Commit 65340df0 authored by Ole Christian Eidheim's avatar Ole Christian Eidheim Committed by eidheim
Browse files

Initial commit

parents
No related branches found
No related tags found
No related merge requests found
Showing
with 742 additions and 0 deletions
node_modules
config.ts
bundle.js
bundle.js.map
coverage
{
"printWidth": 100,
"proseWrap": "always",
"singleQuote": true
}
{
"recommendations": ["esbenp.prettier-vscode"]
}
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2
}
LICENSE 0 → 100644
MIT License
Copyright (c) 2020 ntnu-dcst2002
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# WebSocket example: Whiteboard with client websocket service
## Setup database connections
This example does not use any database. You can therefore create empty `config.ts` files:
```sh
touch server/config.ts server/test/config.ts
```
## Start server
Install dependencies and start server:
```sh
cd server
npm install
npm start
```
### Run server tests:
```sh
npm test
```
Compared to the previous example project, the only additional dependency is
[ws](https://www.npmjs.com/package/ws).
## Bundle client files to be served through server
Install dependencies and bundle client files:
```sh
cd client
npm install
npm start
```
### Run client tests:
```sh
npm test
```
{
"presets": ["@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react"],
"highlightCode": false
}
package-lock=false
{
"name": "whiteboard-client",
"version": "1.0.0",
"description": "A simple Whiteboard web client",
"license": "MIT",
"scripts": {
"start": "webpack --mode development --watch",
"test": "jest --setupFiles ./test/setup.tsx"
},
"jest": {
"testEnvironment": "jsdom",
"snapshotSerializers": [
"enzyme-to-json/serializer"
],
"collectCoverage": true
},
"browserslist": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
],
"dependencies": {
"axios": "^0.21.1",
"react-router-dom": "^5.2.0",
"react-simplified": "^3.0.0"
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@babel/preset-env": "^7.15.0",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.15.0",
"@types/enzyme": "^3.10.9",
"@types/jest": "^27.0.0",
"@types/react-dom": "^17.0.9",
"@types/react-router-dom": "^5.1.8",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.3",
"babel-jest": "^27.0.6",
"babel-loader": "^8.2.2",
"enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"jest": "^27.0.6",
"prettier": "^2.3.2",
"typescript": "^4.3.5",
"webpack": "^5.50.0",
"webpack-cli": "^4.7.2"
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Source diff could not be displayed: it is too large. Options to address this: view the blob.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Todo web application example</title>
<link rel="stylesheet" href="bootstrap.min.css" />
</head>
<body>
<div id="root"></div>
<script src="bootstrap.min.js"></script>
<!-- Embed React application bundled by webpack and babel -->
<script src="bundle.js"></script>
</body>
</html>
import ReactDOM from 'react-dom';
import * as React from 'react';
import { Whiteboard } from './whiteboard-component';
import { Alert } from './widgets';
ReactDOM.render(
<>
<Alert />
<Whiteboard />
</>,
document.getElementById('root')
);
import * as React from 'react';
import { Component } from 'react-simplified';
import whiteboardService, { Subscription } from './whiteboard-service';
import { Alert } from './widgets';
export class Whiteboard extends Component {
canvas: HTMLCanvasElement | null = null;
lastPos: { x: number; y: number } | null = null;
subscription: Subscription | null = null;
connected = false;
render() {
return (
<>
<canvas
ref={(e) => (this.canvas = e) /* Store canvas element */}
onMouseMove={(event) => {
// Send lines to Whiteboard server
const pos = { x: event.clientX, y: event.clientY };
if (this.lastPos && this.connected) {
whiteboardService.send({ line: { from: this.lastPos, to: pos } });
}
this.lastPos = pos;
}}
width={400}
height={400}
style={{ border: '2px solid black' }}
/>
<div>{this.connected ? 'Connected' : 'Not connected'}</div>
</>
);
}
mounted() {
// Subscribe to whiteboardService to receive events from Whiteboard server in this component
this.subscription = whiteboardService.subscribe();
// Called when the subscription is ready
this.subscription.onopen = () => {
this.connected = true;
};
// Called on incoming message
this.subscription.onmessage = (message) => {
const context = this.canvas?.getContext('2d');
context?.beginPath();
context?.moveTo(message.line.from.x, message.line.from.y);
context?.lineTo(message.line.to.x, message.line.to.y);
context?.closePath();
context?.stroke();
};
// Called if connection is closed
this.subscription.onclose = (code, reason) => {
this.connected = false;
Alert.danger('Connection closed with code ' + code + ' and reason: ' + reason);
};
// Called on connection error
this.subscription.onerror = (error) => {
this.connected = false;
Alert.danger('Connection error: ' + error.message);
};
}
// Unsubscribe from whiteboardService when component is no longer in use
beforeUnmount() {
if (this.subscription) whiteboardService.unsubscribe(this.subscription);
}
}
/**
* In and out message type (to and from server).
*/
export type Message = { line: { from: { x: number; y: number }; to: { x: number; y: number } } };
/**
* Subscription class that enables multiple components to receive events from Whiteboard server.
*/
export class Subscription {
onopen: () => void = () => {};
onmessage: (message: Message) => void = () => {};
onclose: (code: number, reason: string) => void = () => {};
onerror: (error: Error) => void = () => {};
}
/**
* Service class to communicate with Whiteboard server.
*
* Variables and functions marked with @private should not be used outside of this class.
*/
class WhiteboardService {
/**
* Connection to Whiteboard server.
*
* @private
*/
connection = new WebSocket('ws://localhost:3000/api/v1/whiteboard');
/**
* Component subscriptions.
*
* @private
*/
subscriptions = new Set<Subscription>(); // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
constructor() {
this.connection.onopen = () => {
// Call subscription onopen functions when connection is ready
this.subscriptions.forEach((subscription) => subscription.onopen());
};
this.connection.onmessage = (event) => {
// Call subscription onmessage functions on messages from Whiteboard server
const data = event.data;
console.log(data);
if (typeof data == 'string')
this.subscriptions.forEach((subscription) => subscription.onmessage(JSON.parse(data)));
};
this.connection.onclose = (event) => {
// Call subscription onclose functions when connection is closed
this.subscriptions.forEach((subscription) => subscription.onclose(event.code, event.reason));
};
this.connection.onerror = () => {
// Call subscription onerror functions on connection error
const error = this.createError();
this.subscriptions.forEach((subscription) => subscription.onerror(error));
};
}
/**
* Create Error object with more helpful information from connection ready state.
*
* @private
*/
createError() {
// Error messages from https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
if (this.connection.readyState == WebSocket.CLOSING)
return new Error('The connection is in the process of closing.');
else if (this.connection.readyState == WebSocket.CLOSED)
return new Error("The connection is closed or couldn't be opened.");
else return new Error();
}
/**
* Returns a subscription that enables multiple components to receive events from Whiteboard server.
*/
subscribe() {
const subscription = new Subscription();
this.subscriptions.add(subscription);
// Call subscription.onopen or subscription.onerror() after subscription is returned
setTimeout(() => {
// Call subscription.onopen() if connection is already opened
if (this.connection.readyState == WebSocket.OPEN) subscription.onopen();
// Call subscription.onerror() if connection is already in a closing or closed state
else if (
this.connection.readyState == WebSocket.CLOSING ||
this.connection.readyState == WebSocket.CLOSED
)
subscription.onerror(this.createError());
});
return subscription;
}
/**
* Given subscription will no longer receive events from Whiteboard server.
*/
unsubscribe(subscription: Subscription) {
this.subscriptions.delete(subscription);
}
/**
* Send message to Whiteboard server.
*/
send(message: Message) {
this.connection.send(JSON.stringify(message));
}
}
const whiteboardService = new WhiteboardService();
export default whiteboardService;
import * as React from 'react';
import { ReactNode, ChangeEvent } from 'react';
import { Component } from 'react-simplified';
import { NavLink } from 'react-router-dom';
/**
* Renders an information card using Bootstrap classes.
*
* Properties: title
*/
export class Card extends Component<{ title: ReactNode }> {
render() {
return (
<div className="card">
<div className="card-body">
<h5 className="card-title">{this.props.title}</h5>
<div className="card-text">{this.props.children}</div>
</div>
</div>
);
}
}
/**
* Renders a row using Bootstrap classes.
*/
export class Row extends Component {
render() {
return <div className="row">{this.props.children}</div>;
}
}
/**
* Renders a column with specified width using Bootstrap classes.
*
* Properties: width, right
*/
export class Column extends Component<{ width?: number; right?: boolean }> {
render() {
return (
<div className={'col' + (this.props.width ? '-' + this.props.width : '')}>
<div className={'float-' + (this.props.right ? 'end' : 'start')}>{this.props.children}</div>
</div>
);
}
}
/**
* Renders a success button using Bootstrap styles.
*
* Properties: small, onClick
*/
class ButtonSuccess extends Component<{ small?: boolean; onClick: () => void }> {
render() {
return (
<button
type="button"
className="btn btn-success"
style={
this.props.small
? {
padding: '5px 5px',
fontSize: '16px',
lineHeight: '0.7',
}
: {}
}
onClick={this.props.onClick}
>
{this.props.children}
</button>
);
}
}
/**
* Renders a danger button using Bootstrap styles.
*
* Properties: small, onClick
*/
class ButtonDanger extends Component<{ small?: boolean; onClick: () => void }> {
render() {
return (
<button
type="button"
className="btn btn-danger"
style={
this.props.small
? {
padding: '5px 5px',
fontSize: '16px',
lineHeight: '0.7',
}
: {}
}
onClick={this.props.onClick}
>
{this.props.children}
</button>
);
}
}
/**
* Renders a light button using Bootstrap styles.
*
* Properties: small, onClick
*/
class ButtonLight extends Component<{ small?: boolean; onClick: () => void }> {
render() {
return (
<button
type="button"
className="btn btn-light"
style={
this.props.small
? {
padding: '5px 5px',
fontSize: '16px',
lineHeight: '0.7',
}
: {}
}
onClick={this.props.onClick}
>
{this.props.children}
</button>
);
}
}
/**
* Renders a button using Bootstrap styles.
*
* Properties: onClick
*/
export class Button {
static Success = ButtonSuccess;
static Danger = ButtonDanger;
static Light = ButtonLight;
}
/**
* Renders a NavBar link using Bootstrap styles.
*
* Properties: to
*/
class NavBarLink extends Component<{ to: string }> {
render() {
return (
<NavLink className="nav-link" activeClassName="active" to={this.props.to}>
{this.props.children}
</NavLink>
);
}
}
/**
* Renders a NavBar using Bootstrap classes.
*
* Properties: brand
*/
export class NavBar extends Component<{ brand: ReactNode }> {
static Link = NavBarLink;
render() {
return (
<nav className="navbar navbar-expand-sm navbar-light bg-light">
<div className="container-fluid justify-content-start">
<NavLink className="navbar-brand" activeClassName="active" exact to="/">
{this.props.brand}
</NavLink>
<div className="navbar-nav">{this.props.children}</div>
</div>
</nav>
);
}
}
/**
* Renders a form label using Bootstrap styles.
*/
class FormLabel extends Component {
render() {
return <label className="col-form-label">{this.props.children}</label>;
}
}
/**
* Renders a form input using Bootstrap styles.
*/
class FormInput extends Component<{
type: string;
value: string | number;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
[prop: string]: any;
}> {
render() {
// ...rest will contain extra passed attributes such as disabled, required, width, height, pattern
// For further information, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
const { type, value, onChange, ...rest } = this.props;
return (
<input
{...rest}
className="form-control"
type={this.props.type}
value={this.props.value}
onChange={this.props.onChange}
/>
);
}
}
/**
* Renders a form textarea using Bootstrap styles.
*/
class FormTextarea extends React.Component<{
value: string | number;
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
[prop: string]: any;
}> {
render() {
// ...rest will contain extra passed attributes such as disabled, required, rows, cols
// For further information, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
const { value, onChange, ...rest } = this.props;
return <textarea {...rest} className="form-control" value={value} onChange={onChange} />;
}
}
/**
* Renders a form checkbox using Bootstrap styles.
*/
class FormCheckbox extends Component<{
checked: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
[prop: string]: any;
}> {
render() {
// ...rest will contain extra passed attributes such as disabled, required, width, height, pattern
// For further information, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
const { checked, onChange, ...rest } = this.props;
return (
<input
{...rest}
className="form-check-input"
type="checkbox"
checked={checked}
onChange={onChange}
/>
);
}
}
/**
* Renders a form select using Bootstrap styles.
*/
class FormSelect extends Component<{
value: string | number;
onChange: (event: ChangeEvent<HTMLSelectElement>) => void;
[prop: string]: any;
}> {
render() {
// ...rest will contain extra passed attributes such as disabled, required, size.
// For further information, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
const { value, onChange, children, ...rest } = this.props;
return (
<select {...rest} className="custom-select" value={value} onChange={onChange}>
{children}
</select>
);
}
}
/**
* Renders form components using Bootstrap styles.
*/
export class Form {
static Label = FormLabel;
static Input = FormInput;
static Textarea = FormTextarea;
static Checkbox = FormCheckbox;
static Select = FormSelect;
}
/**
* Renders alert messages using Bootstrap classes.
*
* Students: this slightly more complex component is not part of curriculum.
*/
export class Alert extends Component {
alerts: { id: number; text: ReactNode; type: string }[] = [];
nextId: number = 0;
render() {
return (
<div>
{this.alerts.map((alert, i) => (
<div
key={alert.id}
className={'alert alert-dismissible alert-' + alert.type}
role="alert"
>
{alert.text}
<button
type="button"
className="btn-close btn-sm"
onClick={() => this.alerts.splice(i, 1)}
/>
</div>
))}
</div>
);
}
/**
* Show success alert.
*/
static success(text: ReactNode) {
// To avoid 'Cannot update during an existing state transition' errors, run after current event through setTimeout
setTimeout(() => {
let instance = Alert.instance(); // Get rendered Alert component instance
if (instance) instance.alerts.push({ id: instance.nextId++, text: text, type: 'success' });
});
}
/**
* Show info alert.
*/
static info(text: ReactNode) {
// To avoid 'Cannot update during an existing state transition' errors, run after current event through setTimeout
setTimeout(() => {
let instance = Alert.instance(); // Get rendered Alert component instance
if (instance) instance.alerts.push({ id: instance.nextId++, text: text, type: 'info' });
});
}
/**
* Show warning alert.
*/
static warning(text: ReactNode) {
// To avoid 'Cannot update during an existing state transition' errors, run after current event through setTimeout
setTimeout(() => {
let instance = Alert.instance(); // Get rendered Alert component instance
if (instance) instance.alerts.push({ id: instance.nextId++, text: text, type: 'warning' });
});
}
/**
* Show danger alert.
*/
static danger(text: ReactNode) {
// To avoid 'Cannot update during an existing state transition' errors, run after current event through setTimeout
setTimeout(() => {
let instance = Alert.instance(); // Get rendered Alert component instance
if (instance) instance.alerts.push({ id: instance.nextId++, text: text, type: 'danger' });
});
}
}
import { configure } from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
configure({ adapter: new Adapter() });
import * as React from 'react';
import { Whiteboard } from '../src/whiteboard-component';
import { shallow } from 'enzyme';
jest.mock('../src/whiteboard-service', () => {
class Subscription {
onopen = () => {};
}
class WhiteboardService {
constructor() {}
subscribe() {
const subscription = new Subscription();
// Call subscription.onopen after subscription is returned
setTimeout(() => subscription.onopen());
return subscription;
}
}
return new WhiteboardService();
});
describe('Whiteboard component tests', () => {
test('draws correctly when connected', (done) => {
const wrapper = shallow(<Whiteboard />);
expect(wrapper.containsMatchingElement(<div>Not connected</div>)).toEqual(true);
setTimeout(() => {
expect(wrapper.containsMatchingElement(<div>Connected</div>)).toEqual(true);
done();
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment