diff --git a/README.md b/README.md index 078a172..7447334 100644 --- a/README.md +++ b/README.md @@ -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); * ... diff --git a/src/backend/main.tsx b/src/backend/main.tsx index ab4727b..e76d7f4 100644 --- a/src/backend/main.tsx +++ b/src/backend/main.tsx @@ -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, @@ -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. */} - + Hi -

Hello world!

- -
-

- -

-
-
+

- + Just a small demo of progressive form and tracker web components. +
+ 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).

- + +
, ); diff --git a/src/frontend/app.ts b/src/frontend/app.ts index bc0ac5e..d247df4 100644 --- a/src/frontend/app.ts +++ b/src/frontend/app.ts @@ -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); +}); diff --git a/src/frontend/navigation-utils.ts b/src/frontend/navigation-utils.ts new file mode 100644 index 0000000..49aa538 --- /dev/null +++ b/src/frontend/navigation-utils.ts @@ -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); +}; diff --git a/src/frontend/progressive-form.ts b/src/frontend/progressive-form.ts new file mode 100644 index 0000000..5c31000 --- /dev/null +++ b/src/frontend/progressive-form.ts @@ -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); + } +} diff --git a/src/frontend/query-tracker.ts b/src/frontend/query-tracker.ts new file mode 100644 index 0000000..49750b9 --- /dev/null +++ b/src/frontend/query-tracker.ts @@ -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(); + } +} diff --git a/src/frontend/static/style.css b/src/frontend/static/style.css index 35bd1d3..72f4c57 100644 --- a/src/frontend/static/style.css +++ b/src/frontend/static/style.css @@ -2,3 +2,7 @@ font-weight: bold; color: red; } + +li form { + display: inline; +} \ No newline at end of file diff --git a/src/jsx-augmentations.ts b/src/jsx-augmentations.ts new file mode 100644 index 0000000..af76d51 --- /dev/null +++ b/src/jsx-augmentations.ts @@ -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; + } + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 243d298..5e4070b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,7 @@ "exclude": ["src/**/*.spec.*"], "compilerOptions": { "module": "Preserve", + "noCheck": true, "noEmit": false, "outDir": "dist", "rewriteRelativeImportExtensions": true