Completion of Form Component, Component Viewer, and Documentation
This commit is contained in:
@@ -1,38 +1,5 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
import ComponentViewer from "./components/ComponentViewer";
|
||||
import Form from "./components/Form";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
<ComponentViewer name="Form Component">
|
||||
<Form />
|
||||
</ComponentViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Style Injection to size component
|
||||
to that of what the slider specifies*/
|
||||
.ComponentViewer div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
25
solution/src/components/ComponentViewer/index.js
Normal file
25
solution/src/components/ComponentViewer/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function ComponentViewer({ name, children }) {
|
||||
const [width, setWidth] = useState(450);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{name}</h1>
|
||||
<label htmlFor="width-slider">Component Width: {width + "px"}</label>
|
||||
<input
|
||||
type="range"
|
||||
id="width-slider"
|
||||
name="width-slider"
|
||||
value={width}
|
||||
min="200"
|
||||
max="900"
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
></input>
|
||||
<br />
|
||||
|
||||
{/* Set the container width to the slider definition */}
|
||||
<div style={{ width: `${width}px` }}>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
solution/src/components/Form/Form.css
Normal file
18
solution/src/components/Form/Form.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.Form-container {
|
||||
border: solid black;
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.Form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.Form-container > form {
|
||||
max-width: 515px;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
50
solution/src/components/Form/index.js
Normal file
50
solution/src/components/Form/index.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import NameInput, { selectedName } from "../atoms/NameInput";
|
||||
import LocationInput, { selectedLocation } from "../atoms/LocationInput";
|
||||
import RenderTable from "../atoms/RenderTable";
|
||||
import "./Form.css";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function Form() {
|
||||
const [table, setTable] = useState([]);
|
||||
const [isNameValid, setIsNameValid] = useState(false);
|
||||
const locationRef = useRef();
|
||||
const nameRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
selectedLocation.subscribe((value) => {
|
||||
locationRef.current = value;
|
||||
});
|
||||
|
||||
selectedName.subscribe((value) => {
|
||||
nameRef.current = value;
|
||||
value.isValid === true ? setIsNameValid(true) : setIsNameValid(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function addToTable() {
|
||||
setTable([
|
||||
{ name: nameRef.current.name, location: locationRef.current },
|
||||
...table,
|
||||
]);
|
||||
}
|
||||
|
||||
function clearTable() {
|
||||
setTable([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Form-container">
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<NameInput />
|
||||
<LocationInput />
|
||||
<div className="Form-actions">
|
||||
<button onClick={clearTable}>Clear</button>
|
||||
<button type="submit" disabled={!isNameValid} onClick={addToTable}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<RenderTable records={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@container (width > 0px) {
|
||||
.LocationInput {
|
||||
container: inline-size;
|
||||
max-width: 515px;
|
||||
margin: 5px 5px 25px 5px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.LocationInput label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.LocationInput > select {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width > 300px) {
|
||||
.LocationInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.LocationInput label {
|
||||
flex: 0 80px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.LocationInput > select {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
font-size: 1rem;
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width > 430px) {
|
||||
.LocationInput label {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
51
solution/src/components/atoms/LocationInput/index.js
Normal file
51
solution/src/components/atoms/LocationInput/index.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getLocations } from "../../../mock-api/apis.js";
|
||||
import "./LocationInput.css";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
// Fetches location options on mount
|
||||
// Shares currently selected location with any listeners (anywhere!)
|
||||
// Finite number of renders -> no pass by props re-rendering
|
||||
export default function LocationInput() {
|
||||
const [options, setOptions] = useState([]);
|
||||
|
||||
// on component mount (Only runs once!)
|
||||
useEffect(() => {
|
||||
// Fetch selectable locations
|
||||
getLocations().then((locations) => {
|
||||
// push the first location to the selectedLocation subject
|
||||
// This is how we share the selected location with listeners
|
||||
selectedLocation.next(locations[0]);
|
||||
|
||||
// Render the location options and save to state for re-render
|
||||
// Options are static -> no need to re-render after this.
|
||||
setOptions(
|
||||
locations.map((option) => {
|
||||
return (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="LocationInput">
|
||||
<label htmlFor="location">Location</label>
|
||||
<select
|
||||
name="location"
|
||||
id="location"
|
||||
onChange={(e) => {
|
||||
// Push new location to listeners
|
||||
selectedLocation.next(e.target.value);
|
||||
}}
|
||||
>
|
||||
{options}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const selectedLocation = new Subject();
|
||||
89
solution/src/components/atoms/NameInput/NameInput.css
Normal file
89
solution/src/components/atoms/NameInput/NameInput.css
Normal file
@@ -0,0 +1,89 @@
|
||||
@container (width > 0px) {
|
||||
.NameInput {
|
||||
container: inline-size;
|
||||
max-width: 515px;
|
||||
margin: 5px 5px 25px 5px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.NameInput label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.NameInput > span > input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.NameInput > span:nth-child(2) > span {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
#Form-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
#Form-pending {
|
||||
color: gold;
|
||||
}
|
||||
|
||||
#Form-valid {
|
||||
color: green;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width > 300px) {
|
||||
.NameInput {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* .NameInput label {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
margin-left: 5px;
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
display: inline-block;
|
||||
} */
|
||||
|
||||
.NameInput span {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.NameInput > span > label,
|
||||
.NameInput > span > span {
|
||||
flex: 0 80px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.NameInput > span > input,
|
||||
.NameInput > span > span:nth-child(2) {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
margin-left: 5px;
|
||||
font-size: 1rem;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.NameInput > span:nth-child(2) > span {
|
||||
font-size: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width > 430px) {
|
||||
.NameInput label,
|
||||
.NameInput > span > input {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.NameInput > span > span:nth-child(2) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
101
solution/src/components/atoms/NameInput/index.js
Normal file
101
solution/src/components/atoms/NameInput/index.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import "./NameInput.css";
|
||||
import { isNameValid as getNameValidity } from "../../../mock-api/apis.js";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Subject, from, switchAll } from "rxjs";
|
||||
|
||||
// The native api does not include the name that was checked
|
||||
// This can cause an incorrect determination of the validity
|
||||
// of the name if the name is changed before the api call is
|
||||
// completed. This function wraps the api call to include the
|
||||
// name that was checked to compare with the current name.
|
||||
function isNameValid(name) {
|
||||
return new Promise((resolve) => {
|
||||
getNameValidity(name).then((value) => {
|
||||
resolve({ name: name, isValid: value });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Checks if the name is valid
|
||||
// Shares the name with any listeners (anywhere!)
|
||||
// Finite number of renders -> no pass by props re-rendering
|
||||
// Guarantees the following
|
||||
// A subscriber will always get the latest name
|
||||
// A subscriber will always know if the 'settled' name is valid
|
||||
// A subscriber can read if a name has not 'settled' and is still pending validation
|
||||
export default function NameInput() {
|
||||
const [name, setName] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
const nameRef = useRef(name);
|
||||
|
||||
// Anytime name is updated
|
||||
useEffect(() => {
|
||||
if (name.length > 0) {
|
||||
// Send the name to the api to check if it is valid (async)
|
||||
let observable = from(isNameValid(name));
|
||||
latestObservable.next(observable);
|
||||
|
||||
// Update the nameRef to validate that the name has not changed
|
||||
// after the above api call was made
|
||||
nameRef.current = name;
|
||||
|
||||
// Broadcast the latest name and it's pending status
|
||||
selectedName.next({ name: name, isValid: "pending" });
|
||||
setIsPending(true);
|
||||
setIsError(false);
|
||||
setIsValid(false);
|
||||
} else {
|
||||
selectedName.next({ name: "", isValid: "false" });
|
||||
setIsPending(false);
|
||||
setIsError(false);
|
||||
setIsValid(false);
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
// Mount the observable listener on component mount
|
||||
useEffect(() => {
|
||||
// Only read the most recent API response and ignore the rest
|
||||
latestObservable.pipe(switchAll()).subscribe((nameObject) => {
|
||||
// Check that the name has not changed since the api call was made
|
||||
if (nameObject.name === nameRef.current) {
|
||||
setIsPending(false);
|
||||
// Show error if name is invalid
|
||||
nameObject.isValid ? setIsError(false) : setIsError(true);
|
||||
nameObject.isValid ? setIsValid(true) : setIsValid(false);
|
||||
|
||||
// Broadcast the latest name and it's validity
|
||||
selectedName.next(nameObject);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="NameInput">
|
||||
<span>
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<span></span> {/* Spacer */}
|
||||
{isError ? (
|
||||
<span id="Form-error">This name has already been taken</span>
|
||||
) : null}
|
||||
{isPending ? <span id="Form-pending">Checking...</span> : null}
|
||||
{isValid ? <span id="Form-valid">Available!</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestObservable = new Subject();
|
||||
export const selectedName = new Subject();
|
||||
35
solution/src/components/atoms/RenderTable/RenderTable.css
Normal file
35
solution/src/components/atoms/RenderTable/RenderTable.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.Form-table {
|
||||
border-top: solid black 2px;
|
||||
border-spacing: 0;
|
||||
empty-cells: show;
|
||||
width: 100%;
|
||||
max-width: 515px;
|
||||
}
|
||||
|
||||
.Form-table th:nth-child(1),
|
||||
.Form-table td:nth-child(1) {
|
||||
border-right: solid black 2px;
|
||||
}
|
||||
|
||||
.Form-table thead tr {
|
||||
background-color: lightgray;
|
||||
text-align: left;
|
||||
border: solid lightgray 2px;
|
||||
}
|
||||
|
||||
.Form-table tbody tr:nth-child(even) {
|
||||
background-color: #ffffff;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.Form-table tbody tr:nth-child(odd) {
|
||||
background-color: #f2f2f2;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@container (width > 515px) {
|
||||
.Form-table {
|
||||
border: solid black 2px;
|
||||
margin: 25px auto;
|
||||
}
|
||||
}
|
||||
33
solution/src/components/atoms/RenderTable/index.js
Normal file
33
solution/src/components/atoms/RenderTable/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import "./RenderTable.css";
|
||||
|
||||
export default function RenderTable({ records }) {
|
||||
const tableRows = records.map((record) => {
|
||||
return (
|
||||
<tr>
|
||||
<td>{record.name}</td>
|
||||
<td>{record.location}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
while (tableRows.length < 5) {
|
||||
tableRows.push(
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="Form-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{tableRows}</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user