Files
TijdVoorDeTest/assets/controllers/bo/form_collection_controller.ts
T
Marijn b4a27a7c0d Migrate frontend to TypeScript with Deno-based tooling (#209)
* Migrate frontend to TypeScript with Deno-based tooling (#206)

Compiles assets/*.ts via sensiolabs/typescript-bundle (standalone SWC
binary) and adds Deno for formatting, linting, type-checking, and
tests, keeping the project's no-Node/npm approach intact. Wires all
four into CI, the Justfile, and the pre-commit hook.

* fix: build TypeScript assets before running PHPUnit in CI

The tests job ran bin/console sass:build but never typescript:build,
so var/typescript/ didn't exist and any page rendering the importmap
(e.g. backoffice/base.html.twig) errored during tests.

* test: add regression test for backoffice navbar-toggler dead target

Guards against the navbar-toggler button pointing at a collapse
target (#navbarSupportedContent) that isn't rendered for the current
user — the bug hit on the login page before #210 restructured the
nav to always render at least one item (the Releases link) regardless
of auth state.

* fix: stop hand-enumerating TS files for deno check

deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
was duplicated in CI and the Justfile, and silently misses any new
controller subdirectory (fmt/lint/test already recurse assets/ via
deno.json). Add a top-level exclude for assets/vendor/ (respected by
all deno subcommands, unlike the per-task include/exclude blocks) so
deno check assets/ can recurse safely instead.

* fix: GitHubReleasesService date parsing and falsy release name

- Move the release-mapping array_map inside the try block so a
  malformed published_at (or any other parse failure) degrades to the
  same empty-list fallback as an HTTP failure, instead of throwing
  uncaught out of the cache callback.
- Stop treating a release literally named "0" as unnamed — the old
  `?:` fallback is falsy for that string and silently substituted the
  tag name instead.
- Render release dates in UTC explicitly; Twig's date filter otherwise
  silently converts to the app's default timezone (Europe/Amsterdam),
  which could show the wrong calendar day for releases near midnight.

* docs: add scope-creep-as-a-service rule to CLAUDE.md

Per user instruction: when a review turns up a real bug outside the
current task's scope, fix it in the same MR (with a regression test)
rather than just reporting it, unless it needs a human design call.
2026-07-12 22:11:00 +02:00

173 lines
5.8 KiB
TypeScript

import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['collection'];
static values = { prototype: String };
declare readonly collectionTarget: HTMLElement;
declare readonly prototypeValue: string;
index = 0;
_dragging: Element | null = null;
_form: HTMLFormElement | null = null;
_submitHandler: (() => void) | null = null;
connect(): void {
this.index = this.collectionTarget.children.length;
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
// `submit` fires on the ancestor <form>, which is outside this controller's
// subtree. Stimulus data-action only works within the controller element, so
// addEventListener on the form is the only option here.
this._form = this.element.closest('form');
if (this._form) {
this._submitHandler = () => {
[...this.collectionTarget.children].forEach((item) => {
const input = item.querySelector<HTMLInputElement>(
'input[type="text"]',
);
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect(): void {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
}
}
addItem(): void {
const item = document.createElement('div');
item.innerHTML = this.prototypeValue.replace(
/__name__/g,
String(this.index),
);
const el = item.firstElementChild;
if (el) this.collectionTarget.appendChild(el);
this.index++;
this._syncOrdering();
}
removeItem(event: Event): void {
(event.target as HTMLElement).closest('[data-collection-item]')
?.remove();
this._notifyChange();
}
sortAlphabetically(): void {
const items = [...this.collectionTarget.children];
items.sort((a, b) => {
const textA =
(a.querySelector<HTMLInputElement>('input[type="text"]')
?.value ?? '').toLowerCase();
const textB =
(b.querySelector<HTMLInputElement>('input[type="text"]')
?.value ?? '').toLowerCase();
return textA.localeCompare(textB);
});
items.forEach((item) => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize(): void {
const items = [...this.collectionTarget.children];
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
items.forEach((item) => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
autoExpand(event: Event): void {
const target = event.target as HTMLInputElement;
if (target.type !== 'text') return;
const item = target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && target.value.trim() !== '') {
this.addItem();
}
}
// — drag-and-drop —
dragStart(event: DragEvent): void {
this._dragging = (event.currentTarget as HTMLElement).closest(
'[data-collection-item]',
);
this._dragging?.classList.add('opacity-50');
event.dataTransfer!.effectAllowed = 'move';
}
dragEnd(event: DragEvent): void {
(event.currentTarget as HTMLElement).closest('[data-collection-item]')
?.classList.remove('opacity-50');
this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]')
.forEach((i) =>
i.classList.remove(
'border-top',
'border-bottom',
'border-primary',
)
);
}
dragOver(event: DragEvent): void {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer!.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
dragLeave(event: DragEvent): void {
(event.currentTarget as HTMLElement).classList.remove(
'border-top',
'border-bottom',
'border-primary',
);
}
drop(event: DragEvent): void {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(
this._dragging,
isBottom ? el.nextSibling : el,
);
this._syncOrdering();
this._notifyChange();
}
_notifyChange(): void {
this.element.dispatchEvent(new Event('change', { bubbles: true }));
}
_syncOrdering(): void {
[...this.collectionTarget.children].forEach((el, i) => {
const input = el.querySelector<HTMLInputElement>(
'input[name*="[ordering]"]',
);
if (input) input.value = String(i);
});
}
}