Completion of Form Component, Component Viewer, and Documentation

This commit is contained in:
2024-04-11 19:50:19 -07:00
parent c5397aa793
commit 6d89fb8657
15 changed files with 517 additions and 60 deletions

View File

@@ -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;
}

View File

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

View File

@@ -0,0 +1,5 @@
/* Style Injection to size component
to that of what the slider specifies*/
.ComponentViewer div {
box-sizing: border-box;
}

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

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

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

View File

@@ -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;
}
}

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

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

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

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

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