REACT

Golf Scorecard React App

This tutorial demonstrates how to create a React application that is used in a great many golf apps, a way to display and edit golf course scorecard data. You can view a demo of the app here

demo with source code inspection using the Chrome developer tools
demo with source maps

There are various ways to get the React application setup for local development as documented on React’s Create a New React App page. We’re going to use the one that React says, “is best way to start building a new single-page application in React.”

Create React App

npx create-react-app golf-scorecard
cd golf-scorecard
npm start

At the time of this writing, the create-react-app script generates a boilerplate app and npm start launches a local development server for you. For example, you should see a page with a animated React logo at localhost:3000.

Let’s start by installing the axios node module that is required by the golf scorecard app. This module is used to get data from and post data to the golf course datasource.

npm i axios

Golf Course Data

In the real world, our application would get the golf course data from a REST API. We’re going to simulate that with a tees.json file that contains the course data in the same format that a typical REST service would respond with.

Save and copy this tees.json file into the golf-scorecard/public folder.

use wget to download the tees.json file. For example:

wget -L https://jimfrenette.com/demo/golf-scorecard/tees.json
 
mv tees.json public/tees.json

Application

Our React application needs a component to render each golf hole on the scorecard. In the src folder, create this hole.js React component file that we will import into our App.js.

hole.js
function Hole(props) {
	let contentOut,
		contentIn,
		contentTotal,
		sum = false;

	if (props.index == 8) {
		contentOut =
			<div className="out">
				<div>OUT</div>
				<div className="yards">{props.yardsOut}</div>
				<div>{props.parOut}</div>
				<div>Hcp.</div>
			</div>;
		sum = true;
	} else if (props.index == 17) {
		contentIn =
			<div className="in">
				<div>IN</div>
				<div className="yards">{props.yardsIn}</div>
				<div>{props.parIn}</div>
				<div>Hcp.</div>
			</div>;
		contentTotal =
			<div>
				<div>&nbsp;</div>
				<div className="yards">{props.yards}</div>
				<div>{props.par}</div>
				<div>&nbsp;</div>
			</div>;
		sum = true;
	}

	return (
		<div className={`hole${sum = sum ? ' sum' : ''}`}>
			<div>
				<div>{props.item.number}</div>
				<div className="yards">{props.item.yards}</div>
				<div>{props.item.par}</div>
				<div>{props.item.handicap}</div>
			</div>
			{contentOut}
			{contentIn}
			{contentTotal}
		</div>
	);

}

export default Hole;

Now we are at the fun part were we are going to edit the boilerplate src/App.js that was created when we ran create-react-app.

App.js
import axios from 'axios';
import { useState, useEffect } from 'react';
import Hole from './hole'

function App() {
	const [loading, setLoading] = useState(false);
	const [course, setCourse] = useState(null);

	let content = {};

    useEffect(() => {
		fetchData();
	}, [])

	const fetchData = async () => {

		const TEE_SVC_URL = 'tees.json';

		try {
			setLoading(true);
			const response = await axios.get(TEE_SVC_URL);
			const data = response.data
			setCourse(data);
			setLoading(false);
		} catch (e) {
			console.log(e);
			setLoading(false);
		}
	}

	if (loading || course == null) {
		content.list = <div>Loading...</div>;
	} else {
		let yardsOut,
			yardsIn,
			yards,
			parOut,
			parIn,
			par;

		content.list = course.tees.map((item) => {
			yardsOut = 0;
			yardsIn = 0;
			yards = 0;
			parOut = 0;
			parIn = 0;
			par = 0;
			return (
				<div className="tee-container">
					<div className="scorecard">
						<div className="header">
							<div className="tee">{item.tee} {item.gender}</div>
						</div>
						{item.holes.map((hole, i) => {
							yards = yards + Number(hole.yards);
							par = par + Number(hole.par);
							if (i < 9) {
								yardsOut = yardsOut + Number(hole.yards);
								parOut = parOut + Number(hole.par);
							}
							if (i > 8) {
								yardsIn = yardsIn + Number(hole.yards);
								parIn = parIn + Number(hole.par);
							}
							return (
								<Hole index={i} item={hole} yardsOut={yardsOut} yardsIn={yardsIn} yards={yards} parOut={parOut} parIn={parIn} par={par} />
							);
						})}
						<div className="footer">
							<div>Slope {item.slope}</div>
							<div>Rating {item.rating}</div>
						</div>
					</div>
				</div>
			);
		});
  }

  return (
	<div className="course">
		{content.list}
	</div>
	)
}

export default App;

The app should render all of the scorecard data now, but without any styling, it’s difficult to understand as shown here.

React golf scorecard without css

Let’s create some Sass files to generate our application css, but first we need to install a Sass processor. You will need to stop the currently running React script first using Ctrl+c. Once your done installing Sass, you can launch the local dev environment again with npm start.

Install Sass

npm i sass

In the src folder, create this tees Sass module.

tees.scss
.tee-container {
	padding-top: 1rem;
	margin-bottom: 1.5rem;
	max-width: 30rem;

	input {
		box-sizing: border-box;
		font-family: inherit;
		box-shadow: 0 0 0 transparent;
		border-radius: 4px;
		border: 1px solid #8c8f94;
		background-color: #fff;
		color: #2c3338;
	}

	input[readonly] {
		background-color: #f0f0f1;
	}
}

.scorecard {
	.header {
		display: flex;
		flex-wrap: wrap;
		width: 100%;
		padding: 0.5rem 0;
		align-items: center;

		.btn {
			margin-left: auto;
		}
		.btn ~ .btn {
			margin-left: 10px;
		}

		.golfer {
			min-width: 100%;
		}
	}

	.hole {
		>div {
			display: flex;
			width: 100%;
		}

		.label-wrap {
			text-align: left;

			.label {
				padding-right: 8px;
			}
		}

		>div>div {
			min-width: 25%;
			min-height: 26px;
			line-height: 26px;
			white-space: nowrap;
			border-bottom: 1px solid rgba(0, 0, 0, 0.1);
		}

		input {
			display: inline-block;
			padding: 0 2px;
			text-align: center;
			width: 24px;
			font-size: inherit;
			line-height: 1.5;
			min-height: 24px;
			text-transform: uppercase;
		}
	}

	.footer {
		display: flex;
		justify-content: flex-end;
		flex-wrap: wrap;
		width: 100%;
		font-size: 90%;
		padding: 0.5rem;

		>div {
			padding: 0.5rem;
		}
	}
}

.edit {

	.tee-container {
		max-width: 700px;
	}

	.scorecard {
		display: flex;
		flex-wrap: wrap;
		max-width: none;
		width: 100%;

		.adv-toggle {
			cursor: pointer;
		}

		.hole {
			min-width: 8%;
			text-align: center;

			&.labels {
				min-width: 14%;
				flex-grow: 2;
				display: flex;

				>div {
					width: 60%;
				}
			}

			&.sum {
				min-width: 26%;
				flex-grow: 3;
				display: flex;

				>div {
					width: 33%;
				}
			}

			>div {
				flex-direction: column;
			}

			.adv.hide {
				display: none;
			}

			&.sum~.hole {
				margin-top: 1.5rem;
			}
		}

		.footer {
			justify-content: flex-start;
			align-items: flex-end;
			padding-left: 0;
		}

		.info {
			margin-left: auto;

			>div {
				display: inline-block;
				padding: 0.5rem;
			}
		}

		.date-field {
			padding-left: 0;
		}

		.footer label {
			display: block;
		}

		.notes {
			width: 100%;

			>textarea {
				width: 100%;
			}
		}

		.submit {
			padding-right: 1rem;
			padding-left: 2rem;
		}

		.footer>div:last-child {
			flex: 0 0 100%;
			display: flex;
			justify-content: space-between;
			padding-left: 0;
		}
	}
}

/* 480 @16 */
@media screen and (min-width: 30em) {
	.course-modal {
		font-size: 16px;
	}
}

/* 768 @16 */
@media screen and (min-width: 48em) {
	.tee-container {
		max-width: 40rem;
	}

	.scorecard {
		display: flex;
		flex-wrap: wrap;
		max-width: none;
		width: 100%;

		.hole {
			min-width: 9%;
			text-align: center;

			&.sum {
				min-width: 26%;
				flex-grow: 3;
				display: flex;

				>div {
					width: 33%;
				}

				~.hole {
					margin-top: 1.5rem;
				}
			}

			>div {
				flex-direction: column;
			}
		}
	}
}

/* 1024 @16 */
@media screen and (min-width: 64em) {
	.tee-container {
		max-width: 64em;
	}

	.scorecard .hole {
		display: flex;
		align-items: flex-start;
		min-width: 4.5%;

		&.sum {
			min-width: 10%;

			~.hole {
				margin-top: auto;
			}
		}
	}
}

In the golf-scorecard/src folder, rename App.css to app.scss.

# navigate to the src directory
cd src

mv App.css app.scss

Add this line to app.scss for importing the new tees Sass module.

app.scss
@import "./tees";

Now we need to import the app.scss into our React App. You can add this import near the top of the App after the Hole component import. For example

App.js
import axios from 'axios';
import { useState, useEffect } from 'react';
import Hole from './hole'
import './app.scss';

...

Now with some styling, it is beginning to look like a golf scorecard.

React golf scorecard with css

Score Modal

Now we will add the components for a modal where scores can be entererd for a course. Each course will have a Score button to open the form for entering scores.

In the src folder, create the button component file in a new folder named button as follows.

# you should be in the src directory

mkdir button
cd button
touch index.js

What we did here was create a button sub directory with a new index.js file within it.

Add the following JavaScript to our button/index.js.

index.js
function Button(props) {
	return (
		<div className='btn'>
			<button onClick={(evt) => props.click(evt, props.item)} disabled={props.disabled}>
				{props.icon && <span className={props.icon}></span>}
				<span className="btn-text">{props.label}</span>
			</button>
		</div>
	)
}

export default Button;

Our modal requires both an index.js and a use.js file, so let’s create the modal sub directory and its two empty javascript files that we will add code to later.

# you should be in the src directory

mkdir modal
cd modal
touch index.js use.js

For our modal, we’re going to create a custom React hook. Open the use.js file for editing and add the following custom react hook code.

use.js
import { useState, useEffect } from 'react';
/**
 * useModal
 * @returns Custom React Hook
 * usage:`const {modalView, modalToggle} = useModal();`
 *       `modalToggle();` e.g., for onClick
 */
const useModal = () => {
	const [modalView, setModalView] = useState(false);

	/**
	 * TODO
	 * Fix this so when multiple modals are open this only applies to the first modal
	 */
	useEffect(() => {
		if (modalView) {
			document.addEventListener('keydown', handleKeyDown);
			document.body.classList.add('light-modal-open');
		} else {
			document.body.classList.remove('light-modal-open');
		}
		return () => document.removeEventListener('keydown', handleKeyDown);
	}, [modalView]);

	function modalToggle() {
		setModalView(!modalView);
	}

	function handleKeyDown(event) {
		if (event.keyCode !== 27) return;
		modalToggle();
	}

	return {
		modalView,
		modalToggle,
	}
};

export default useModal;

Add the following JavaScript to our modal/index.js to output the modal markup.

index.js
import React from 'react';
import ReactDOM from 'react-dom'

function Modal(props) {
	const fragClass = `light-modal ${props.classes || ''}`.trim();

	return props.modalView ? ReactDOM.createPortal(
		<React.Fragment>
			<div className={fragClass} aria-modal aria-hidden tabIndex={-1} role="dialog" /*onClick={props.hide}*/>
				<div className="light-modal-content animated zoomInUp">
					<div className="light-modal-header">
						<h3 className="light-modal-heading">{props.content.heading}</h3>
						<a href="#" className="light-modal-close-icon" onClick={props.hide} aria-label="close">×</a>
					</div>
					<div className="light-modal-body">
						{props.content.body}
					</div>
					<div className="light-modal-footer">
						<a href="#" className="light-modal-close-btn" onClick={props.hide} aria-label="close">Close</a>
						{props.content.footer}
					</div>
				</div>
			</div>
		</React.Fragment>, document.body
	) : null;
}

export default Modal;

Update App.js now that we have the modal dependencies created:

App.js
...

function App() {
  const [loading, setLoading] = useState(false);
  const [course, setCourse] = useState(null);
  const [modalClass, setModalClass] = useState(null);
  const [modalContent, setModalContent] = useState(null);
  const { modalView, modalToggle } = useModal();

...

Score Entry

Create this score.js React component file to render our score entry form.

score.js
import { useState, useEffect } from 'react';
// import axios from 'axios'; // for REST API
import Button from "./button";
import HoleScore from './hole-score'

function Score(props) {
	const [loading, setLoading] = useState(false);
	const [locked, setLocked] = useState(null);
	const [posted, setPosted] = useState(null);
	const [strokesOut, setStrokesOut] = useState(0);
	const [strokesIn, setStrokesIn] = useState(0);
	const [strokesTotal, setStrokesTotal] = useState(0);
	const [puttsOut, setPuttsOut] = useState(0);
	const [puttsIn, setPuttsIn] = useState(0);
	const [puttsTotal, setPuttsTotal] = useState(0);
	const [fairwayOut, setFairwayOut] = useState(0);
	const [fairwayIn, setFairwayIn] = useState(0);
	const [fairwayTotal, setFairwayTotal] = useState(0);
	const [bunkerOut, setBunkerOut] = useState(0);
	const [bunkerIn, setBunkerIn] = useState(0);
	const [bunkerTotal, setBunkerTotal] = useState(0);
	const [penaltyOut, setPenaltyOut] = useState(0);
	const [penaltyIn, setPenaltyIn] = useState(0);
	const [penaltyTotal, setPenaltyTotal] = useState(0);

	let content = {};

	useEffect(() => {
		init();
	}, []);

	const postData = async (data) => {
		/** In the real world, this would post to a REST API.
			For this demo, we will use session storage and sessionScore state. */

		// const SCORE_SVC_URL = `${rest_api_base_url}/score`;
		const body = JSON.stringify(data);

		try {
			// setLoading(true);
			// const response = await axios.post(SCORE_SVC_URL, body, {
			// 	headers: {
			// 		'Content-Type': 'application/json'
			// 	}
			// });

			setLoading(false);

			/** for REST API */
			// handleResponse(response);

			/** for demo */
			window.sessionStorage.setItem('golf_scorecard', body)
			setPosted(
				<div>
					<div>Score: <b>{data.strokesTotal}</b></div>
					<div>{data.course}, {data.location}</div>
					<div>{data.tee}, {data.gender}, rating {data.rating} / slope {data.slope}</div>
					<div>{data.date}, {data.time}</div>
					<div>Notes: {data.notes}</div>
				</div>);
		} catch (e) {
			console.log(e);
			setLoading(false);
		}
	}

	let score,
		yardsOut = 0,
		yardsIn = 0,
		yards = 0,
		parOut = 0,
		parIn = 0,
		par = 0;

	function init() {
		const allowed = ['L', 'F', 'R'];

		[].slice.call(document.querySelectorAll(`input[name^='fairway_']`)).forEach((el) => {
			el.addEventListener('keydown', function (evt) {
				switch (evt.key) {
					case 'Up': // IE/Edge
					case 'ArrowUp':
						evt.target.value = 'F';
						break;
					case 'Left': // IE/Edge
					case 'ArrowLeft':
						evt.target.value = 'L';
						break;
					case 'Right': // IE/Edge
					case 'ArrowRight':
						evt.target.value = 'R';
						break;
					default:
						evt.target.value = evt.target.value.toUpperCase();
						if (allowed.includes(evt.target.value)) {
							return;
						}
						if (evt.key && !allowed.includes(evt.key.toUpperCase())) {
							evt.target.value = '';
						}
				}

				handleScoreChange(evt);
			}, true);
		});

		if (props.action == 'update') {
			setLocked('readonly');
			setStrokesOut(props.content.strokesOut);
			setStrokesIn(props.content.strokesIn);
			setStrokesTotal(props.content.strokesTotal);
			setPuttsOut(props.content.puttsOut);
			setPuttsIn(props.content.puttsIn);
			setPuttsTotal(props.content.puttsTotal);
			setFairwayOut(props.content.fairwayOut);
			setFairwayIn(props.content.fairwayIn);
			setFairwayTotal(props.content.fairwayTotal);
			setBunkerOut(props.content.bunkerOut);
			setBunkerIn(props.content.bunkerIn);
			setBunkerTotal(props.content.bunkerTotal);
			setPenaltyOut(props.content.penaltyOut);
			setPenaltyIn(props.content.penaltyIn);
			setPenaltyTotal(props.content.penaltyTotal);
		}
	}

	function advToggle(evt) {
		evt.preventDefault();

		[].slice.call(document.querySelectorAll('.edit .hole .adv')).forEach((el) => {
			if (el.classList.contains('hide')) {
				el.classList.remove('hide');
			} else {
				el.classList.add('hide');
			}
		}, true);
	}

	function lockToggle(evt) {
		evt.preventDefault();

		if (locked == 'readonly') {
			setLocked(null);
		} else {
			setLocked('readonly');
		}
	}

	function sumScores(nodeList) {
		let sum = {};
		sum.out = 0;
		sum.in = 0;

		[].slice.call(nodeList).forEach((el, index) => {
			if (index < 9) {
				sum.out = sum.out + Number(el.value);
			} else {
				sum.in = sum.in + Number(el.value);
			}
		});

		return sum;
	}

	/**
	 * Replaces non-digit element value with an empty string
	 * @param {*} el
	 * @returns
	 */
	function digitVal(el) {
		if (el.value && isNaN(el.value)) {
			return el.value.replace(/[^\d]+/g,'');
		} else {
			return el.value;
		}
	}

	function handleSubmit(evt) {
		evt.preventDefault();

		score = props.content;

		score['yardsOut'] = yardsOut;
		score['yardsIn'] = yardsIn;
		score['yards'] = yards;
		score['parOut'] = parOut;
		score['parIn'] = parIn;
		score['par'] = par;
		score['strokesOut'] = strokesOut;
		score['strokesIn'] = strokesIn;
		score['strokesTotal'] = strokesTotal;
		score['puttsOut'] = puttsOut;
		score['puttsIn'] = puttsIn;
		score['puttsTotal'] = puttsTotal;
		score['fairwayOut'] = fairwayOut;
		score['fairwayIn'] = fairwayIn;
		score['fairwayTotal'] = fairwayTotal;
		score['bunkerOut'] = bunkerOut;
		score['bunkerIn'] = bunkerIn;
		score['bunkerTotal'] = bunkerTotal;
		score['penaltyOut'] = penaltyOut;
		score['penaltyIn'] = penaltyIn;
		score['penaltyTotal'] = penaltyTotal;

		score['date'] = evt.target.elements.date.value;
		score['time'] = evt.target.elements.time.value;
		score['notes'] = evt.target.elements.notes.value;

		Array.prototype.map.call(evt.target.elements, (el) => {
			let name = el.name.split('_');
			if (name[1] && el.value.length == 0) {
				delete score.holes[name[1]-1][name[0]];
			} else if (name[1]) {
				score.holes[name[1]-1][name[0]] = el.value;
			}
		});

		postData(score);
	}

	function handleScoreChange(evt) {
		if (evt.target.name.startsWith('strokes_')) {
			evt.target.value = digitVal(evt.target);
			const strokes = document.querySelectorAll(`input[name^='strokes_']`);
			const sumStrokes = sumScores(strokes);

			setStrokesOut(sumStrokes.out);
			setStrokesIn(sumStrokes.in);
			setStrokesTotal(sumStrokes.in + sumStrokes.out);
		}
		if (evt.target.name.startsWith('putts_')) {
			evt.target.value = digitVal(evt.target);
			const putts = document.querySelectorAll(`input[name^='putts_']`);
			const sumPutts = sumScores(putts);

			setPuttsOut(sumPutts.out);
			setPuttsIn(sumPutts.in);
			setPuttsTotal(sumPutts.in + sumPutts.out);
		}
		if (evt.target.name.startsWith('bunker_')) {
			evt.target.value = digitVal(evt.target);
			const bunker = document.querySelectorAll(`input[name^='bunker_']`);
			const sumBunker = sumScores(bunker);

			setBunkerOut(sumBunker.out);
			setBunkerIn(sumBunker.in);
			setBunkerTotal(sumBunker.in + sumBunker.out);
		}
		if (evt.target.name.startsWith('penalty_')) {
			evt.target.value = digitVal(evt.target);
			const penalty = document.querySelectorAll(`input[name^='penalty_']`);
			const sumPenalty = sumScores(penalty);

			setPenaltyOut(sumPenalty.out);
			setPenaltyIn(sumPenalty.in);
			setPenaltyTotal(sumPenalty.in + sumPenalty.out);
		}
		if (evt.target.name.startsWith('fairway_')) {
			const fairway = document.querySelectorAll(`input[name^='fairway_']`);
			let sum = {};
			sum.out = 0;
			sum.in = 0;

			[].slice.call(fairway).forEach((el, index) => {
				if (el.value == 'F') {
					if (index < 9) {
						sum.out = sum.out + 1;
					} else {
						sum.in = sum.in + 1;
					}
				}
			});

			setFairwayOut(sum.out);
			setFairwayIn(sum.in);
			setFairwayTotal(sum.in + sum.out);
		}
	}

    /** Uncomment this function when using a REST API **/
	// function handleResponse(response) {
	// 	const data = JSON.parse(response.config.data);
	// 	if (response.status == 200) {
	// 		setPosted(
	// 			<div>
	// 				<div>Score: <b>{data.strokesTotal}</b></div>
	// 				<div>{data.course}, {data.location}</div>
	// 				<div>{data.tee}, {data.gender}, rating {data.rating} / slope {data.slope}</div>
	// 				<div>{data.date}, {data.time}</div>
	// 				<div>Notes: {data.notes}</div>
	// 			</div>);
	// 	} else {
	// 		setPosted(
	// 			<div>
	// 				<div>Error: <b>{response.status}</b> {response.statusText}</div>
	// 			</div>);
	// 	}
	// }

	if (posted) {
		content =
		<div>Score Posted</div>
	} else {
		content =
		<form onSubmit={(evt) => handleSubmit(evt)}>
			<div className="tee-container">
			<div className="scorecard">
				<div className="header">
					<div className="tee">{props.content.tee} {props.content.gender}</div>
					{props.action == 'update' &&
						<Button click={lockToggle} label={`${locked == 'readonly' ? 'edit' : 'view'}`} />
					}
				</div>
				{props.content.holes.map((hole, i) => {
					yards = yards + Number(hole.yards);
					par = par + Number(hole.par);
					if (i < 9) {
						yardsOut = yardsOut + Number(hole.yards);
						parOut = parOut + Number(hole.par);
					}
					if (i > 8) {
						yardsIn = yardsIn + Number(hole.yards);
						parIn = parIn + Number(hole.par);
					}
					return (
						<HoleScore
							locked={locked}
							index={i}
							item={hole}
							yardsOut={yardsOut}
							yardsIn={yardsIn}
							yards={yards}
							parOut={parOut}
							parIn={parIn}
							par={par}
							strokesOut={strokesOut}
							strokesIn={strokesIn}
							strokesTotal={strokesTotal}
							puttsOut={puttsOut}
							puttsIn={puttsIn}
							puttsTotal={puttsTotal}
							fairwayOut={fairwayOut}
							fairwayIn={fairwayIn}
							fairwayTotal={fairwayTotal}
							bunkerOut={bunkerOut}
							bunkerIn={bunkerIn}
							bunkerTotal={bunkerTotal}
							penaltyOut={penaltyOut}
							penaltyIn={penaltyIn}
							penaltyTotal={penaltyTotal}
							advToggle={advToggle}
							scoreChange={handleScoreChange} />
					);
				})}
				<div className="footer">
					<div className="date-field">
						<input type="date" name="date" id="date" defaultValue={props.content.date} readOnly={locked} />
					</div>
					<div className="time-field">
						<input type="time" name="time" id="time" defaultValue={props.content.time} readOnly={locked} />
					</div>
					<div className="info">
						<div>Slope {props.content.slope}</div>
						<div>Rating {props.content.rating}</div>
					</div>
					<div>
						<div className="notes">
							<label htmlFor="notes">Notes</label>
							<textarea name="notes" id="notes" defaultValue={props.content.notes} readOnly={locked} />
						</div>
						<div className="submit">
							<input type="submit" value="Submit" disabled={`${locked == 'readonly' ? 'disabled' : ''}`} />
						</div>
					</div>
				</div>
			</div>
			</div>
		</form>
	}

	return (
		<div className="score">
			{content}
			<div>{posted}</div>
		</div>
	);
}

export default Score;

Create this hole-score.js React component file to render all of the inputs for each hole in our score entry form.

hole-score.js
import Button from "./button";

function HoleScore(props) {
	let contentLabels,
		contentOut,
		contentIn,
		contentTotal,
		bunkerInput = `bunker_${props.item.number}`,
		fairwayInput = `fairway_${props.item.number}`,
		penaltyInput = `penalty_${props.item.number}`,
		puttsInput = `putts_${props.item.number}`,
		strokesInput = `strokes_${props.item.number}`,
		labels = false,
		sum = false;

	if (props.index == 0 || props.index == 9) {
		contentLabels =
			<div className="label-wrap">
				<div>&nbsp;</div>
				<div>Yards</div>
				<div>Par</div>
				<div className="label">Strokes</div>
				<div className="adv-toggle">
					<Button click={props.advToggle} label="adv" />
				</div>
				<div className="label adv">Putts</div>
				<div className="label adv">Fairway</div>
				<div className="label adv">Bunker</div>
				<div className="label adv">Penalty</div>
			</div>;
		labels = true;
	} else if (props.index == 8) {
		contentOut =
			<div className="out">
				<div>OUT</div>
				<div className="yards">{props.yardsOut}</div>
				<div>{props.parOut}</div>
				<div className="label strokes">{props.strokesOut}</div>
				<div>Hcp.</div>
				<div className="label adv putts">{props.puttsOut}</div>
				<div className="label adv fairway">{props.fairwayOut}</div>
				<div className="label adv bunker">{props.bunkerOut}</div>
				<div className="label adv penalty">{props.penaltyOut}</div>
			</div>;
		sum = true;
	} else if (props.index == 17) {
		contentIn =
			<div className="in">
				<div>IN</div>
				<div className="yards">{props.yardsIn}</div>
				<div>{props.parIn}</div>
				<div className="label strokes">{props.strokesIn}</div>
				<div>Hcp.</div>
				<div className="label adv putts">{props.puttsIn}</div>
				<div className="label adv fairway">{props.fairwayIn}</div>
				<div className="label adv bunker">{props.bunkerIn}</div>
				<div className="label adv penalty">{props.penaltyIn}</div>
			</div>;
		contentTotal =
			<div>
				<div>&nbsp;</div>
				<div className="yards">{props.yards}</div>
				<div>{props.par}</div>
				<div className="label strokes">{props.strokesTotal}</div>
				<div>&nbsp;</div>
				<div className="label adv putts">{props.puttsTotal}</div>
				<div className="label adv fairway">{props.fairwayTotal}</div>
				<div className="label adv bunker">{props.bunkerTotal}</div>
				<div className="label adv penalty">{props.penaltyTotal}</div>
			</div>;
		sum = true;
	}

	return (
		<div className={`hole${sum = sum ? ' sum' : ''}${labels = labels ? ' labels' : ''}`}>
			{contentLabels}
			<div>
				<div>{props.item.number}</div>
				<div className="yards">{props.item.yards}</div>
				<div>{props.item.par}</div>
				<div><input name={strokesInput} type="tel" required="" aria-required="true" onChange={props.scoreChange} maxLength="2" defaultValue={props.item.strokes} readOnly={props.locked} /></div>
				<div>{props.item.handicap}</div>
				<div className="adv"><input name={puttsInput} type="tel" required="" aria-required="false" onChange={props.scoreChange} maxLength="1" defaultValue={props.item.putts} readOnly={props.locked} /></div>
				<div className="adv"><input name={fairwayInput} type="text" required="" aria-required="false" maxLength="1" defaultValue={props.item.fairway} readOnly={props.locked} /></div>
				<div className="adv"><input name={bunkerInput} type="tel" required="" aria-required="false" onChange={props.scoreChange} maxLength="1" defaultValue={props.item.bunker} readOnly={props.locked} /></div>
				<div className="adv"><input name={penaltyInput} type="tel" required="" aria-required="false" onChange={props.scoreChange} maxLength="1" defaultValue={props.item.penalty} readOnly={props.locked} /></div>
			</div>
			{contentOut}
			{contentIn}
			{contentTotal}
		</div>
	);

}

export default HoleScore;

Import the button, modal, score components and use modal hook at the top of our App.js. For example,

App.js
import axios from 'axios';
import Button from "./button";
import { useState, useEffect } from 'react';
import useModal from './modal/use';
import Modal from "./modal";
import Hole from './hole'
import Score from './score';
import './app.scss';

...

Each tee will have a button that a user clicks to enter a score. This click event calls a function that opens a Modal. The function accepts the evt (short for event) and item parameters. The item parameter contains all of the tee properties that our score entry form will need to render the scorecard for the respective tee.

Add the handleScoreClick function that the button click event will bind to. You can always refer to the source code in its entirety for more detail.

App.js
function handleScoreClick(evt, item) {
	let action = 'create';
	let sessionItem = JSON.parse(sessionStorage.getItem('golf_scorecard'));
	if (sessionItem && sessionItem.tee_id == item.tee_id) {
		item = sessionItem;
		action = 'update';
	}

	try {
		item['course'] = course.name;
		item['location'] = course.location;

		setModalClass("fullscreen edit");
		setModalContent({
			"heading": `${course.name}, ${course.location}`,
			"body": <Score content={item} action={action} />
		});

		modalToggle();
	} catch (e) {
		console.log(e);
	}
}
...

Update the application to render our score buttons for each tee. Locate this section of code that returns markup for each tee and add our Button component, <Button item={item} click={handleScoreClick} label='score' />. For example,

App.js
...
return (
    <div className="tee-container">
        <div className="scorecard">
            <div className="header">
                <div className="tee">{item.tee} {item.gender}</div>
                <Button item={item} click={handleScoreClick} label='score' />
            </div>
...

Then locate the final return near the bottom of the application and add our Modal component, <Modal modalView={modalView} hide={modalToggle} content={modalContent} classes={modalClass} />. For example,

App.js
...
return (
	<div className="course">
		{content.list}
		<Modal modalView={modalView} hide={modalToggle} content={modalContent} classes={modalClass} />
	</div>
	)
...

Clicking the score button now will output the Modal component markup with our form at the bottom of our app. Let’s add some css so the modal has the style it needs to overlay our course and tees content. Add the modal import to the top of our app.scss file.

app.scss
@import "./modal/modal";
@import "./tees";

Create this Sass in the modal folder. For example,

modal.scss
body.light-modal-open {
	overflow: hidden;
}

.light-modal {
	--lm-body-bg: #FFF;
	--lm-close-bg: #FFF;
	--lm-small-modal: 30vw;
	--lm-large-modal: 50vw;
	--lm-font-size: 16px;
	position: fixed;
	background: rgba(0, 0, 0, 0.5);
	top: 0;
	bottom: 0;
	left: 0;
	align-items: center;
	display: flex;
	justify-content: center;
	right: 0;
	z-index: 9000;
	transition: background 1s;
	font-size: var(--lm-font-size);
}

.light-modal-content {
	background: var(--lm-body-bg);
	color: #000;
	width: var(--lm-small-modal);
	border-radius: 0.2rem;
	position: relative;
	max-height: calc(100vh - 150px);
	line-height: 1.4;
	display: flex;
	flex-direction: column;
}

.light-modal-header {
	padding: 20px 20px 20px 20px;
	background: var(--lm-body-bg);
	display: flex;
	justify-content: space-between;
	box-shadow: 0px 0px 60px -3px rgb(0 0 0 / 33%);
}

.light-modal-heading {
	color: #000;
	margin: 0;
	font-size: 1.5rem;
}

.light-modal-body {
	padding: 20px;
	overflow: auto;
	max-height: 450px;
}

.light-modal-footer {
	font-size: 0.875rem;
	padding: 20px 20px 20px 20px;
	background: var(--lm-body-bg);
	text-align: right;
	display: flex;
	justify-content: space-between;
	align-items: center;
}

.light-modal-close-icon,
.light-modal-close-btn {
	text-decoration: none;
	color: #000;
	background: var(--lm-close-bg);
	box-shadow: none !important;
}

.light-modal-close-icon {
	position: relative;
	width: 2.5rem;
	height: 2.5rem;
	font-size: 2rem;
	line-height: 2.5rem;
	transition: all .4s;
	text-align: center;
	border-radius: 100%;

	&:hover {
		transform: scale(1.1);
	}
}

.light-modal-close-btn {
	line-height: 1;
	padding: 4px 8px;
	border-radius: 0.2rem;

	&:hover {
		transform: scale(1.1);
	}
}

.light-modal.fullscreen {
	.light-modal-body {
		max-height: none;
	}

	.light-modal-content {
		width: 100%;
		max-height: 100%;
	}
}

@media (max-width: 1000px) {
	.light-modal {
		--lm-small-modal: 70vw;
		--lm-large-modal: 70vw;
	}
}

Now we can enter the scores for a round on The Old Course at St. Andrews

React golf scorecard entry

Clicking the adv button toggles showing the “Advanced Scoring” fields. The Fairway fields have a listener on its keydown event to translate curser key entry into a character value. For example,

translates to L for tee shots left of the fairway.
translates to R for tee shots right of the fairway.
 ↑  translates to F for tee shots in the fairway.

All of the other hole fields only accept digits since their type is set to tel.

Totals are updated as each hole field is changed.

React golf scorecard entry confirmed

Response handler shows the posted score confirmation message.

React golf scorecard edit posted score

Posted score may be edited and reposted by selecting the edit button.


Source Code

React Golf Scorecard App tutorial on YouTube

comments powered by Disqus