App

Bundles

Stats
BundleMinifiedGzipBrotli
index.js14 kB5.23 kB4.77 kB

Source

/**
 * @param {{ value: string; onInput: preact.JSX.GenericEventHandler<HTMLInputElement>; }} props
 */
export function FilterInput({ value, onInput }) {
	return (
		<div class="form-group">
			<label class="form-label">Filter prefix: </label>
			<input type="text" class="form-input" value={value} onInput={onInput} />
		</div>
	);
}
/**
 * @param {{ id: string; value: string; onInput: preact.JSX.GenericEventHandler<HTMLInputElement>; label: string; required?: boolean; }} props
 */
export function NameInput({ id, value, onInput, label, required = false }) {
	// TODO: Show error message if required == true but value == ""
	return (
		<div class="form-group">
			<label for={id} class="form-label">
				{label}{" "}
			</label>
			<input
				id={id}
				class="form-input"
				type="text"
				value={value}
				onInput={onInput}
				required={required}
			/>
		</div>
	);
}
import { getDisplayName } from "./util";

/**
 * @param {{ persons: import('./index').Person[]; selectedId: number; onChange: (e: Event) => void;}} props
 */
export function PersonSelect({ persons, selectedId, onChange }) {
	return (
		<div class="form-group">
			<label class="form-label">Select a person to edit:</label>
			<select size={5} onChange={onChange} class="form-select">
				{persons.map(person => {
					return (
						<option
							key={person.id}
							value={person.id}
							selected={person.id == selectedId}
						>
							{getDisplayName(person)}
						</option>
					);
				})}
			</select>
		</div>
	);
}
import { render, Component } from "preact";
import { createApi } from "../../../../lib/crud";
import { NameInput } from "./NameInput";
import { FilterInput } from "./FilterInput";
import { EMPTY_PERSON, getDisplayName } from "./util";
import { PersonSelect } from "./PersonSelect";

const { listAll, create, read, update, remove } = createApi();

function Loading() {
	return <div>Loading...</div>;
}

/**
 * @typedef {import('lib/crud').Person} Person
 * @typedef { "initial" | "create" | "update" | "delete" | null } LoadingState;
 * @typedef {{ filter: string; name: string; surname: string; selectedPersonId: number; persons: Person[]; loading: LoadingState; }} State
 * @returns {State}
 */
function getInitialState() {
	return {
		filter: "",
		name: "",
		surname: "",
		selectedPersonId: null,
		persons: [],
		loading: "initial"
	};
}

class App extends Component {
	constructor(props, context) {
		super(props, context);

		/** @type {State} */
		this.state = getInitialState();

		this.onNameInput = this.onNameInput.bind(this);
		this.onSurnameInput = this.onSurnameInput.bind(this);
		this.onFilterInput = this.onFilterInput.bind(this);
		this.onPersonSelect = this.onPersonSelect.bind(this);
		this.onCreate = this.onCreate.bind(this);
		this.onUpdate = this.onUpdate.bind(this);
		this.onDelete = this.onDelete.bind(this);
	}

	componentDidMount() {
		// TODO: Cancel request if unmounted
		// TODO: handle rejections
		listAll().then(persons => {
			const person = persons.length ? persons[0] : EMPTY_PERSON;
			this.setState({
				persons,
				name: person.name,
				surname: person.surname,
				selectedPersonId: person.id,
				loading: null
			});
		});
	}

	onNameInput(e) {
		this.setState({ name: e.target.value });
	}

	onSurnameInput(e) {
		this.setState({ surname: e.target.value });
	}

	onFilterInput(e) {
		this.setState({ filter: e.target.value });
	}

	onPersonSelect(e) {
		const selectedId = e.target.value;
		const selectedPerson =
			this.state.persons.filter(person => person.id == selectedId)[0] ||
			EMPTY_PERSON;
		this.setState({
			name: selectedPerson.name,
			surname: selectedPerson.surname,
			selectedPersonId: selectedPerson.id
		});
	}

	onCreate() {
		const { name, surname } = this.state;

		this.setState({ loading: "create" });
		// TODO: cancel this request if the component is unmounted
		// TODO: handle rejections
		create(name, surname).then(person => {
			this.setState({
				// TODO: Which inputs do we disable? If not all, then we need to handle
				// one of the inputs changing while the request happens
				selectedPersonId: person.id,
				persons: this.state.persons.concat(person),
				loading: null
			});
		});
	}

	onUpdate() {
		const { selectedPersonId, name, surname } = this.state;

		this.setState({ loading: "update" });
		// TODO: cancel this request if the component is unmounted
		// TODO: handle rejections
		update(selectedPersonId, name, surname).then(person => {
			const persons = this.state.persons.map(p => {
				return p.id == person.id ? person : p;
			});

			this.setState({
				// TODO: Which inputs do we disable? If not all, then we need to handle
				// one of the inputs changing while the request happens
				selectedPersonId: person.id,
				persons,
				loading: null
			});
		});
	}

	onDelete() {
		const { selectedPersonId, name, surname } = this.state;

		this.setState({ loading: "delete" });
		// TODO: cancel this request if the component is unmounted
		// TODO: handle rejections
		remove(selectedPersonId).then(deletedPerson => {
			/** @type {number} */
			let oldIndex;
			const persons = this.state.persons.filter((p, i) => {
				if (p.id == deletedPerson.id) {
					oldIndex = i;
					return false;
				} else {
					return true;
				}
			});

			const newSelectedPerson =
				oldIndex < persons.length ? persons[oldIndex] : persons[oldIndex - 1];

			this.setState({
				// TODO: Which inputs do we disable? If not all, then we need to handle
				// one of the inputs changing while the request happens
				selectedPersonId: newSelectedPerson.id,
				name: newSelectedPerson.name,
				surname: newSelectedPerson.surname,
				persons,
				loading: null
			});
		});
	}

	/**
	 * @param {{}} props
	 * @param {State} state
	 */
	render(props, state) {
		if (state.loading == "initial") {
			return <Loading />;
		}

		let selectedPerson = EMPTY_PERSON;
		let isUpdated = false;
		if (state.selectedPersonId != null) {
			selectedPerson = state.persons.find(p => p.id == state.selectedPersonId);
			isUpdated =
				state.name != selectedPerson.name ||
				state.surname != selectedPerson.surname;
		}

		// TODO: Consider if filteredPersons should be state so that the
		// value of the options can be the index in the filteredPersons array
		// and the selectedPerson is just the index in the filteredPersons array
		let filteredPersons = state.persons;
		if (state.filter) {
			filteredPersons = state.persons.filter(person => {
				return getDisplayName(person)
					.toLowerCase()
					.startsWith(state.filter.toLowerCase());
			});
		}

		return (
			<fieldset class="crud-wrapper" disabled={state.loading != null}>
				<legend>People manager</legend>
				<FilterInput value={state.filter} onInput={this.onFilterInput} />
				<PersonSelect
					persons={filteredPersons}
					selectedId={state.selectedPersonId}
					onChange={this.onPersonSelect}
				/>
				<NameInput
					id="name"
					value={state.name}
					onInput={this.onNameInput}
					label="Name:"
					required
				/>
				<NameInput
					id="surname"
					value={state.surname}
					onInput={this.onSurnameInput}
					label="Surname:"
				/>
				<div class="form-group btn-group btn-group-block">
					<button
						type="button"
						class="btn"
						onClick={this.onCreate}
						disabled={state.name == ""}
					>
						Create
					</button>
					<button
						type="button"
						class="btn"
						onClick={this.onUpdate}
						disabled={state.name == "" || !isUpdated}
					>
						Update
					</button>
					<button
						type="button"
						class="btn"
						onClick={this.onDelete}
						disabled={state.selectedPersonId == null}
					>
						Delete
					</button>
				</div>
				{/* TODO: Add loading state display */}
				{/* <div class="loading loading-lg"></div> */}
			</fieldset>
		);
	}
}

render(<App />, document.getElementById("app"));

// Tests:
// - list all works (loading is shown, list is shown)
// - list all rejects
// - filter includes selection (list is updated, still selected, inputs show right value)
// - filter removes selection (list is updated, select next valid item, inputs shows new selection)
// - inputs show validation error if name input is empty (create & updated are disabled, error message under name is shown)
// - create works (form disabled, form re-enabled, added to list, selected in list, inputs show name)
// - create rejects
// - update works (form disabled, form re-enabled, updated name in list, selected in list, input shows updated name)
// - update is disabled if names are the same
// - update rejects
// - delete works (form disabled, form re-enabled, removed from list, next item selected, input shows new selection)
// - delete rejects
// - All deletes: should update the text fields
// - All deletes: verify filter + delete work (next selected person shouldn't be filtered)
// - Delete all people from top
// - Delete all people from bottom
// - Delete first person (n)
// - Delete middle person (5?)
// - Delete second to last person (4 total)
// - Delete last person (n)
/**
 * @param {import('lib/crud').Person} person
 */
export function getDisplayName(person) {
	return `${person.surname}, ${person.name}`;
}

/** @type {import('lib/crud').Person} */
export const EMPTY_PERSON = Object.freeze({
	id: null,
	name: "",
	surname: ""
});