implemented frontend part of progressive form and trackers

feature/modern-browsers
Inga 🏳‍🌈 2 weeks ago
parent 98fc37530d
commit 06abd9883a
  1. 3
      README.md
  2. 63
      src/backend/main.tsx
  3. 8
      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; * ~0.5 hours to set up the project;
* ~5 hours to implement game board serialization, tic-tac-toe rules and game solver (with tests); * ~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"; import { safeGetQueryValue, sendHtml } from "./utils.ts";
export const mainPageHandler: RequestHandler = (req, res) => { export const mainPageHandler: RequestHandler = (req, res) => {
const a = parseInt(safeGetQueryValue(req, "a") ?? "0", 10); const counters = {
const b = parseInt(safeGetQueryValue(req, "b") ?? "0", 10); a: parseInt(safeGetQueryValue(req, "a") ?? "0", 10),
b: parseInt(safeGetQueryValue(req, "b") ?? "0", 10),
};
sendHtml( sendHtml(
res, res,
<html> <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, 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. 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" /> <link rel="stylesheet" href="frontend/static/style.css" />
<script src="frontend/app.js" type="module"></script>
<title>Hi</title> <title>Hi</title>
</head> </head>
<body> <body>
<p>Hello world!</p> <section>
<p>
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>
<ul> <ul>
{(["a", "b"] as const).map((key) => (
<li> <li>
Value of "a": <span class="counter">{a}</span> <form method="post" is="progressive-form">
</li> <button
<li> type="submit"
Value of "b": <span class="counter">{b}</span> 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> </li>
))}
</ul> </ul>
<form method="post"> </section>
<p>
<button type="submit" name={`a`} value={`${a + 1}`}>
Increase "a"
</button>
</p>
</form>
<form method="post">
<p>
<button type="submit" name={`b`} value={`${b + 1}`}>
Increase "b"
</button>
</p>
</form>
</body> </body>
</html>, </html>,
); );

@ -1,6 +1,14 @@
import { computeAllSolutions } from "../shared/solver.ts"; import { computeAllSolutions } from "../shared/solver.ts";
import { rules } from "../shared/tictactoe-rules.ts"; import { rules } from "../shared/tictactoe-rules.ts";
import { ProgressiveForm } from "./progressive-form.ts";
import { ButtonTracker, SpanTracker } from "./query-tracker.ts";
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); const allSolutions = computeAllSolutions(3, 3, rules);
console.log(allSolutions.size); console.log(allSolutions.size);
console.log(allSolutions); 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; font-weight: bold;
color: red; 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.*"], "exclude": ["src/**/*.spec.*"],
"compilerOptions": { "compilerOptions": {
"module": "Preserve", "module": "Preserve",
"noCheck": true,
"noEmit": false, "noEmit": false,
"outDir": "dist", "outDir": "dist",
"rewriteRelativeImportExtensions": true "rewriteRelativeImportExtensions": true

Loading…
Cancel
Save