implemented frontend part of progressive form and trackers

feature/modern-browsers
Inga 🏳‍🌈 2 weeks ago
parent 98fc37530d
commit 06abd9883a
  1. 3
      README.md
  2. 67
      src/backend/main.tsx
  3. 14
      src/frontend/app.ts
  4. 31
      src/frontend/navigation-utils.ts
  5. 26
      src/frontend/progressive-form.ts
  6. 99
      src/frontend/query-tracker.ts
  7. 4
      src/frontend/static/style.css
  8. 11
      src/jsx-augmentations.ts
  9. 1
      tsconfig.build.json

@ -30,5 +30,6 @@ Source: https://www.programmfabrik.de/en/assignment-frontend-backend-developer-j
* ~0.5 hours to set up the project;
* ~5 hours to implement game board serialization, tic-tac-toe rules and game solver (with tests);
* ~1 hour to set up the backend / frontend structure with JSX templating and static resources
* ~1 hour to set up the backend / frontend structure with JSX templating and static resources;
* ~2 hours to implement progressively enhanced form and query string value trackers (both on backend and in web components);
* ...

@ -2,8 +2,10 @@ import type { RequestHandler } from "express";
import { safeGetQueryValue, sendHtml } from "./utils.ts";
export const mainPageHandler: RequestHandler = (req, res) => {
const a = parseInt(safeGetQueryValue(req, "a") ?? "0", 10);
const b = parseInt(safeGetQueryValue(req, "b") ?? "0", 10);
const counters = {
a: parseInt(safeGetQueryValue(req, "a") ?? "0", 10),
b: parseInt(safeGetQueryValue(req, "b") ?? "0", 10),
};
sendHtml(
res,
<html>
@ -13,34 +15,51 @@ export const mainPageHandler: RequestHandler = (req, res) => {
because we're loading the compiled file on frontend, from path relative to the root page,
and because TS won't rewrite the extension here.
*/}
<script src="frontend/app.js" type="module"></script>
<link rel="stylesheet" href="frontend/static/style.css" />
<script src="frontend/app.js" type="module"></script>
<title>Hi</title>
</head>
<body>
<p>Hello world!</p>
<ul>
<li>
Value of "a": <span class="counter">{a}</span>
</li>
<li>
Value of "b": <span class="counter">{b}</span>
</li>
</ul>
<form method="post">
<p>
<button type="submit" name={`a`} value={`${a + 1}`}>
Increase "a"
</button>
</p>
</form>
<form method="post">
<section>
<p>
<button type="submit" name={`b`} value={`${b + 1}`}>
Increase "b"
</button>
Just a small demo of progressive form and tracker web components.
<br />
It works both with JS and without JS; it does not require network connection when JS is used; and it
supports history navigation (you can go back & forward and see how the state changes).
</p>
</form>
<ul>
{(["a", "b"] as const).map((key) => (
<li>
<form method="post" is="progressive-form">
<button
type="submit"
name={key}
value={`${counters[key] - 1}`}
is="button-query-tracker"
track={key}
delta="-1"
>
-
</button>{" "}
<button
type="submit"
name={key}
value={`${counters[key] + 1}`}
is="button-query-tracker"
track={key}
delta="+1"
>
+
</button>
</form>{" "}
Value of "{key}":{" "}
<span class="counter" is="span-query-tracker" track={key}>
{counters[key]}
</span>
</li>
))}
</ul>
</section>
</body>
</html>,
);

@ -1,6 +1,14 @@
import { computeAllSolutions } from "../shared/solver.ts";
import { rules } from "../shared/tictactoe-rules.ts";
import { ProgressiveForm } from "./progressive-form.ts";
import { ButtonTracker, SpanTracker } from "./query-tracker.ts";
const allSolutions = computeAllSolutions(3, 3, rules);
console.log(allSolutions.size);
console.log(allSolutions);
customElements.define("progressive-form", ProgressiveForm, { extends: "form" });
customElements.define("span-query-tracker", SpanTracker, { extends: "span" });
customElements.define("button-query-tracker", ButtonTracker, { extends: "button" });
document.addEventListener("DOMContentLoaded", function () {
const allSolutions = computeAllSolutions(3, 3, rules);
console.log(allSolutions.size);
console.log(allSolutions);
});

@ -0,0 +1,31 @@
// Until https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API support is widespread
let lastUrl = document.location.href;
const observers = new Set<() => void>();
const checkChanges = () => {
if (document.location.href !== lastUrl) {
lastUrl = document.location.href;
for (const observer of observers) {
observer();
}
}
};
let pollingInterval: number | null = null;
const startPolling = () => {
if (pollingInterval === null) {
pollingInterval = window.setInterval(checkChanges, 20);
}
};
export const onLocationChange = (observer: () => void) => {
observers.add(observer);
startPolling();
};
export const onLocationChangeUnsubscribe = (observer: () => void) => {
observers.delete(observer);
};

@ -0,0 +1,26 @@
const submitListener = function (this: HTMLFormElement, e: SubmitEvent) {
const targetUrl = new URL(this.action);
const formData = new FormData(this, e.submitter);
for (const [key, value] of formData.entries()) {
if (typeof value !== "string") {
throw new Error("File fields are not supported; falling back to regular form submission");
}
targetUrl.searchParams.set(key, value);
}
window.history.pushState({}, "", targetUrl.toString());
e.preventDefault();
};
export class ProgressiveForm extends HTMLFormElement {
connectedCallback() {
if (this.method.toUpperCase() !== "POST") {
throw new Error(
"Only supported when method=POST and progressive form router is used on backend; falling back to regular forms",
);
}
this.addEventListener("submit", submitListener);
}
}

@ -0,0 +1,99 @@
import { onLocationChange, onLocationChangeUnsubscribe } from "./navigation-utils.ts";
const createQueryStringTracker = (key: string, onChange: (newValue: string | null) => void) => {
let currentValue = new URLSearchParams(window.location.search).get(key);
const observer = () => {
const newValue = new URLSearchParams(window.location.search).get(key);
if (newValue !== currentValue) {
currentValue = newValue;
onChange(newValue);
}
};
onLocationChange(observer);
return () => {
onLocationChangeUnsubscribe(observer);
};
};
class TrackingTools<
TElement extends HTMLElement & { handleTrackedValueUpdate(this: TElement, newValue: string | null): void },
> {
private currentTrackKey: string | null = null;
private clearTracker: (() => void) | null = null;
constructor(private readonly element: TElement) {}
private setTrackerIfNeeded() {
const trackKey = this.element.getAttribute("track");
if (trackKey != this.currentTrackKey) {
if (this.clearTracker) {
this.clearTracker();
}
if (trackKey) {
this.clearTracker = createQueryStringTracker(
trackKey,
this.element.handleTrackedValueUpdate.bind(this.element),
);
}
this.currentTrackKey = trackKey;
}
}
connectedCallback() {
this.setTrackerIfNeeded();
}
attributeChangedCallback() {
this.setTrackerIfNeeded();
}
disconnectedCallback() {
if (this.clearTracker) {
this.clearTracker();
}
}
}
export class SpanTracker extends HTMLSpanElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) {
this.innerText = newValue ?? "[null]";
}
connectedCallback() {
this.trackingTools.connectedCallback();
}
attributeChangedCallback() {
this.trackingTools.attributeChangedCallback();
}
disconnectedCallback() {
this.trackingTools.disconnectedCallback();
}
}
export class ButtonTracker extends HTMLButtonElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) {
const delta = parseInt(this.getAttribute("delta") ?? "0", 10);
this.value = (parseInt(newValue ?? "0", 10) + delta).toString();
}
connectedCallback() {
this.trackingTools.connectedCallback();
}
attributeChangedCallback() {
this.trackingTools.attributeChangedCallback();
}
disconnectedCallback() {
this.trackingTools.disconnectedCallback();
}
}

@ -2,3 +2,7 @@
font-weight: bold;
color: red;
}
li form {
display: inline;
}

@ -0,0 +1,11 @@
declare module "preact/jsx-runtime" {
// eslint-disable-next-line @typescript-eslint/no-namespace -- preact uses namespaces, so we have too
namespace JSX {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done
interface HTMLAttributes {
// custom attribute used by query-tracker web components
delta?: string;
track?: string;
}
}
}

@ -3,6 +3,7 @@
"exclude": ["src/**/*.spec.*"],
"compilerOptions": {
"module": "Preserve",
"noCheck": true,
"noEmit": false,
"outDir": "dist",
"rewriteRelativeImportExtensions": true

Loading…
Cancel
Save