Compare commits

..

2 Commits

Author SHA1 Message Date
Marijn 4e98909f11 Add GitHub Releases modal to backoffice (#210)
* Add GitHub Releases modal to backoffice

Shows release notes (fetched from the GitHub API, cached for an hour,
Markdown rendered via league/commonmark) from a top-right nav button
available whether logged in or out, with the current version shown in
the modal header. Also clears cache.app on container start so the
release cache can't carry stale data across redeploys of the
var/ Docker volume.

* Render release notes markdown via twig/markdown-extra instead of manual CommonMark

twig/extra-bundle already wires the markdown filter to league/commonmark
automatically once twig/markdown-extra is installed, so the service no
longer needs to instantiate and call CommonMarkConverter itself.

* Explicitly disallow unsafe link protocols in rendered markdown

twig/extra-bundle's default (allow_unsafe_links: true) permits
javascript:/vbscript:/file:/data: URLs in rendered links, kept for
backwards compatibility. Disable it explicitly since we now render
release note markdown through this filter.

* Autolink bare URLs in rendered release notes

The Twig CommonMark converter only registers CommonMarkCoreExtension by
default, which doesn't turn bare URLs into clickable links (unlike
GitHub's own renderer). Register CommonMark's AutolinkExtension via the
twig.markdown.league_extension tag so PR/compare URLs in release notes
render as actual hyperlinks instead of plain text.

* Address review feedback on GitHubReleasesService and tests

- Extract the cache callback into a named fetchReleases() method instead
  of an inline closure with all the fetch/parse logic.
- Simplify the User-Agent header to just "TijdVoorDeTest".
- Add a 5s HTTP timeout so a slow GitHub API can't block the request.
- Cache HTTP failures for only 60s instead of the full hour, so a brief
  GitHub outage doesn't suppress releases for as long.
- Replace deprecated word-wrap with overflow-wrap in release-notes CSS.
- Extract duplicated mock setup in ReleasesControllerTest into a helper.
- Reword the "could not load" translation ("Kan" instead of "Kon").

* Address second round of CodeRabbit feedback

- Escape raw HTML embedded in release-note markdown instead of passing
  it through, via twig_extra.commonmark.html_input: escape.
- Don't cache GitHub API failures at all (set $save = false in the
  cache callback) instead of caching them for a short TTL, so the very
  next request retries immediately.
- Sort releases by published_at descending before mapping, so the
  first entry (used for the current-version badge and default-expanded
  accordion item) is guaranteed to be the newest regardless of the
  order GitHub's API happens to return.
2026-07-12 18:59:36 +00:00
Marijn a6f1c3cecd Bold the correct answer in the raw answers export sheet (#208)
Makes it easy to visually spot which answer was correct when scanning
the GDPR export's per-candidate/per-question crosstab.
2026-07-12 18:56:46 +02:00
54 changed files with 1406 additions and 1431 deletions
+1 -21
View File
@@ -12,12 +12,7 @@ while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_TWIG+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true)
STAGED_TS=()
while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_TS+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.ts$' || true)
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 && ${#STAGED_TS[@]} -eq 0 ]]; then
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 ]]; then
exit 0
fi
@@ -51,18 +46,3 @@ if [[ ${#STAGED_TWIG[@]} -gt 0 ]]; then
"${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}"
git add "${STAGED_TWIG[@]}"
fi
if [[ ${#STAGED_TS[@]} -gt 0 ]]; then
echo "TypeScript (${#STAGED_TS[@]} file(s)): Deno fmt → lint → check"
echo " → Deno fmt"
"${DOCKER_CMD[@]}" deno fmt "${STAGED_TS[@]}"
git add "${STAGED_TS[@]}"
echo " → Deno lint"
"${DOCKER_CMD[@]}" deno lint --fix "${STAGED_TS[@]}"
git add "${STAGED_TS[@]}"
echo " → Deno check"
"${DOCKER_CMD[@]}" deno check "${STAGED_TS[@]}"
fi
-22
View File
@@ -104,22 +104,6 @@ jobs:
echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
exit 1
fi
- name: TypeScript Formatting
id: ts_fmt
continue-on-error: true
run: docker compose exec -T php deno fmt --check assets/
- name: TypeScript Lint
id: ts_lint
continue-on-error: true
run: docker compose exec -T php deno lint assets/
- name: TypeScript Type Check
id: ts_check
continue-on-error: true
run: docker compose exec -T php deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
- name: TypeScript Tests
id: ts_test
continue-on-error: true
run: docker compose exec -T php deno test assets/
- name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost
- name: Assert all checks passed
@@ -139,10 +123,6 @@ jobs:
check "PHPStan" "${{ steps.phpstan.outcome }}"
check "Rector" "${{ steps.rector.outcome }}"
check "Translations" "${{ steps.translations.outcome }}"
check "TypeScript Formatting" "${{ steps.ts_fmt.outcome }}"
check "TypeScript Lint" "${{ steps.ts_lint.outcome }}"
check "TypeScript Type Check" "${{ steps.ts_check.outcome }}"
check "TypeScript Tests" "${{ steps.ts_test.outcome }}"
exit $failed
tests:
@@ -176,8 +156,6 @@ jobs:
run: docker compose up php database --wait --no-build
- name: Build SCSS
run: docker compose exec -T php bin/console sass:build
- name: Build TypeScript
run: docker compose exec -T php bin/console typescript:build
- name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations
+2 -8
View File
@@ -23,7 +23,7 @@ Tech Stack:
- **ORM**: Doctrine
- **Server**: FrankenPHP with Caddy
- **Container**: Docker Compose
- **Frontend**: Twig templates with SASS and TypeScript (via asset mapper)
- **Frontend**: Twig templates with SASS (via asset mapper)
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
## Build & Development Commands
@@ -252,8 +252,7 @@ question counts as covered more than once).
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure
before touching the production code. Fix the code until the test passes.
- For bugs in `assets/*.ts` logic, write a `deno test` first instead — same TDD rule, different runner. Only skip a
test if the bug is in markup/DOM wiring that isn't worth a test per the rule below.
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists
in a template). Tests cover behavior: routing, forms, persistence, authorization.
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
@@ -277,11 +276,6 @@ question counts as covered more than once).
- **Twig-CS-Fixer**: Template style enforcement
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
they throw exceptions instead
- **TypeScript (`assets/`)**: Compiled via `sensiolabs/typescript-bundle` (standalone SWC binary, no Node/npm).
Formatting, linting, type-checking, and tests use **Deno** (`deno fmt` / `deno lint` / `deno check` / `deno test`) —
a single standalone binary, kept dev-only (installed in the `frankenphp_dev` Docker stage, not prod), consistent
with the project's no-Node-anywhere approach. See `deno.json` for config; run via `just fix-ts` / `just check-ts` /
`just test-ts`. Tests live alongside their source as `*_test.ts` files
### Environment Configuration
-7
View File
@@ -62,18 +62,11 @@ ENV APP_ENV=dev XDEBUG_MODE=off
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
bash-completion \
unzip \
&& rm -rf /var/lib/apt/lists/*
COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console
COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer
# Deno: standalone binary (no Node/npm) used for TypeScript lint/format/type-check/test,
# dev-only tooling so it's not installed in the prod stage.
ENV DENO_INSTALL=/usr/local
# hadolint ignore=DL4006
RUN curl -fsSL https://deno.land/install.sh | sh -s v2.9.2
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \
-10
View File
@@ -112,16 +112,6 @@ phpstan *args:
test *args:
docker compose exec php vendor/bin/phpunit {{ args }}
fix-ts:
docker compose exec php deno fmt assets/
docker compose exec php deno lint --fix assets/
check-ts:
docker compose exec php deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
test-ts *args:
docker compose exec php deno test assets/ {{ args }}
[confirm]
clean:
docker compose down -v --remove-orphans
@@ -2,15 +2,12 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss';
import '@hotwired/turbo';
import './stimulus.ts';
import './bootstrap.ts';
import './stimulus.js';
import './bootstrap.js';
import * as Sentry from '@sentry/browser';
const dsn = document.querySelector<HTMLMetaElement>('meta[name="sentry-dsn"]')
?.content ?? '';
const userEmail =
document.querySelector<HTMLMetaElement>('meta[name="user-email"]')
?.content ?? '';
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? '';
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? '';
// When no real DSN is configured, route to the local Spotlight sidecar so
// nothing reaches Sentry. A syntactically valid DSN is still required for the
+1
View File
@@ -0,0 +1 @@
import * as bootstrap from 'bootstrap'
-1
View File
@@ -1 +0,0 @@
import 'bootstrap';
@@ -0,0 +1,136 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['collection'];
static values = {prototype: String};
connect() {
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('input[type="text"]');
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect() {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
}
}
addItem() {
const item = document.createElement('div');
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild;
this.collectionTarget.appendChild(el);
this.index++;
this._syncOrdering();
}
removeItem(event) {
event.target.closest('[data-collection-item]').remove();
this._notifyChange();
}
sortAlphabetically() {
const items = [...this.collectionTarget.children];
items.sort((a, b) => {
const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
return textA.localeCompare(textB);
});
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize() {
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) {
if (event.target.type !== 'text') return;
const item = event.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && event.target.value.trim() !== '') {
this.addItem();
}
}
// — drag-and-drop —
dragStart(event) {
this._dragging = event.currentTarget.closest('[data-collection-item]');
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
}
dragEnd(event) {
event.currentTarget.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) {
event.preventDefault();
const el = event.currentTarget;
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) {
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
}
drop(event) {
event.preventDefault();
const el = event.currentTarget;
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() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
}
_syncOrdering() {
[...this.collectionTarget.children].forEach((el, i) => {
const input = el.querySelector('input[name*="[ordering]"]');
if (input) input.value = i;
});
}
}
@@ -1,172 +0,0 @@
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);
});
}
}
@@ -1,114 +0,0 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`,
// and target getters are normally installed by Application.register — since we
// construct the controller directly (no real Stimulus app), collectionTarget/element
// are assigned here as plain writable properties, backed by a minimal fake DOM
// container that supports the one operation these methods actually rely on:
// appendChild() moving an existing child to the end (real DOM semantics).
class FakeInput {
value: string;
constructor(value = '') {
this.value = value;
}
}
class FakeItem {
textInput: FakeInput;
orderingInput = new FakeInput();
constructor(text: string) {
this.textInput = new FakeInput(text);
}
querySelector<T>(selector: string): T | null {
if (selector.includes('type="text"')) {
return this.textInput as unknown as T;
}
if (selector.includes('ordering')) {
return this.orderingInput as unknown as T;
}
return null;
}
}
class FakeCollection {
children: FakeItem[] = [];
appendChild(item: FakeItem) {
const idx = this.children.indexOf(item);
if (idx !== -1) this.children.splice(idx, 1);
this.children.push(item);
}
}
const { default: FormCollectionController } = await import(
'./form_collection_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(items: FakeItem[]): any {
const collectionTarget = new FakeCollection();
collectionTarget.children = items;
// `element` is a read-only getter on Controller (delegates to `this.scope.element`),
// so the fake element is supplied via the constructor context rather than assigned.
const controller = new FormCollectionController({
scope: { element: { dispatchEvent: () => true } },
} as never);
return Object.assign(controller, { collectionTarget });
}
Deno.test("_syncOrdering writes the current index into each item's ordering input", () => {
const items = [new FakeItem('c'), new FakeItem('a'), new FakeItem('b')];
const controller = makeController(items);
controller._syncOrdering();
assertEquals(items.map((i) => i.orderingInput.value), ['0', '1', '2']);
});
Deno.test('sortAlphabetically reorders items by their text input value, case-insensitively', () => {
const c = new FakeItem('Charlie');
const a = new FakeItem('alice');
const b = new FakeItem('Bob');
const controller = makeController([c, a, b]);
controller.sortAlphabetically();
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.textInput.value
),
['alice', 'Bob', 'Charlie'],
);
// _syncOrdering must run after the reorder, against the new order
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.orderingInput.value
),
['0', '1', '2'],
);
});
Deno.test('randomize keeps the same set of items and resyncs ordering', () => {
const items = [
new FakeItem('1'),
new FakeItem('2'),
new FakeItem('3'),
new FakeItem('4'),
];
const controller = makeController([...items]);
controller.randomize();
const resultValues = controller.collectionTarget.children.map((
i: FakeItem,
) => i.textInput.value);
assertEquals(resultValues.slice().sort(), ['1', '2', '3', '4']);
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.orderingInput.value
),
['0', '1', '2', '3'],
);
});
+49
View File
@@ -0,0 +1,49 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
export default class extends Controller {
static targets = ['modal', 'frame'];
open(event) {
event.preventDefault();
const {src, modalTitle} = event.currentTarget.dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
markDirty() {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty() {
this._dirty = false;
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
-66
View File
@@ -1,66 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
import { visit } from '@hotwired/turbo';
// Bootstrap's public Modal type doesn't expose `_config` (an internal, but stable, field
// across all 5.x releases) — extend it locally rather than casting to `any` everywhere.
interface ModalWithConfig extends Modal {
_config: { backdrop: boolean | 'static'; keyboard: boolean };
}
export default class extends Controller {
static targets = ['modal', 'frame'];
declare readonly modalTarget: HTMLElement;
declare readonly frameTarget: HTMLElement;
_dirty = false;
open(event: MouseEvent): void {
event.preventDefault();
const { src, modalTitle } =
(event.currentTarget as HTMLElement).dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this.frameTarget.innerHTML =
'<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
if (src) this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameSubmitEnd(event: CustomEvent<{ success: boolean }>): void {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
markDirty(): void {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(
this.modalTarget,
) as ModalWithConfig;
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty(): void {
this._dirty = false;
const modal = Modal.getOrCreateInstance(
this.modalTarget,
) as ModalWithConfig;
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
@@ -0,0 +1,13 @@
import {Controller} from '@hotwired/stimulus';
import {Popover} from 'bootstrap';
export default class extends Controller {
connect() {
this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')]
.map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl));
}
disconnect() {
this.popovers.forEach(popover => popover.dispose());
}
}
@@ -1,19 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { Popover } from 'bootstrap';
export default class extends Controller {
popovers: Popover[] = [];
connect(): void {
this.popovers = [
...this.element.querySelectorAll('[data-bs-toggle="popover"]'),
]
.map((popoverTriggerEl) =>
Popover.getOrCreateInstance(popoverTriggerEl)
);
}
disconnect(): void {
this.popovers.forEach((popover) => popover.dispose());
}
}
@@ -0,0 +1,123 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
connect() {
this._locked = false;
}
dragStart(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
this._dragging = item;
event.dataTransfer.effectAllowed = 'move';
setTimeout(() => item.classList.add('opacity-50'), 0);
}
dragEnd(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
item.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event) {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer.dropEffect = 'move';
const target = event.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
}
dragLeave(event) {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
this._removePlaceholder();
}
}
async drop(event) {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
}
_removePlaceholder() {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state) {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder() {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId);
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
}
}
@@ -1,161 +0,0 @@
import { Controller } from '@hotwired/stimulus';
type Status = 'saving' | 'saved' | 'error';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
declare readonly listTarget: HTMLElement;
declare readonly itemTargets: HTMLElement[];
declare readonly statusTarget: HTMLElement;
declare readonly hasStatusTarget: boolean;
declare readonly reorderUrlValue: string;
declare readonly csrfValue: string;
declare readonly savedLabelValue: string;
declare readonly errorLabelValue: string;
declare readonly errorHintValue: string;
_locked = false;
_dragging: HTMLElement | null = null;
_placeholder: HTMLDivElement | null = null;
connect(): void {
this._locked = false;
}
dragStart(event: DragEvent): void {
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
this._dragging = item;
event.dataTransfer!.effectAllowed = 'move';
setTimeout(() => item?.classList.add('opacity-50'), 0);
}
dragEnd(event: DragEvent): void {
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
item?.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event: DragEvent): void {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer!.dropEffect = 'move';
const target = (event.target as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2
? target.nextSibling
: target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
}
dragLeave(event: DragEvent): void {
if (
!event.relatedTarget ||
!this.listTarget.contains(event.relatedTarget as Node)
) {
this._removePlaceholder();
}
}
async drop(event: DragEvent): Promise<void> {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
}
_removePlaceholder(): void {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state: Status): void {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove(
'd-none',
'text-bg-success',
'text-bg-danger',
'text-bg-warning',
);
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder(): Promise<void> {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId ?? '');
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {
method: 'POST',
body: params,
});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${
this.errorLabelValue || 'Error saving order'
} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
}
}
@@ -1,140 +0,0 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`,
// and target/value getters (listTarget, csrfValue, ...) are normally installed by
// Application.register — since we construct the controller directly (no real
// Stimulus app), they're assigned here as plain writable properties instead.
class FakeClassList {
classes = new Set<string>();
add(...names: string[]) {
names.forEach((n) => this.classes.add(n));
}
remove(...names: string[]) {
names.forEach((n) => this.classes.delete(n));
}
contains(name: string) {
return this.classes.has(name);
}
}
function fakeElement() {
return {
classList: new FakeClassList(),
textContent: '',
after: () => {},
setAttribute: () => {},
};
}
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
createElement: () => ({
...fakeElement(),
innerHTML: '',
}),
};
const { default: QuestionListController } = await import(
'./question_list_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(overrides: Record<string, unknown> = {}): any {
const controller = new QuestionListController({} as never);
return Object.assign(controller, {
listTarget: fakeElement(),
itemTargets: [],
hasStatusTarget: true,
statusTarget: fakeElement(),
csrfValue: 'csrf-token',
reorderUrlValue: '/reorder',
savedLabelValue: 'Saved',
errorLabelValue: 'Error',
errorHintValue: 'Refresh the page.',
...overrides,
});
}
Deno.test('_setStatus renders the saving state', () => {
const controller = makeController();
controller._setStatus('saving');
assertEquals(
controller.statusTarget.classList.contains('text-bg-warning'),
true,
);
assertEquals(controller.statusTarget.textContent, '…');
});
Deno.test('_setStatus renders the saved state with the configured label', () => {
const controller = makeController();
controller._setStatus('saved');
assertEquals(
controller.statusTarget.classList.contains('text-bg-success'),
true,
);
assertEquals(controller.statusTarget.textContent, 'Saved');
});
Deno.test('_setStatus is a no-op when there is no status target', () => {
const controller = makeController({ hasStatusTarget: false });
controller._setStatus('saving');
assertEquals(controller.statusTarget.textContent, '');
});
Deno.test('_persistOrder posts the item ordering and reports success', async () => {
const numberEl = { textContent: '' };
const item = {
dataset: { questionId: '42' },
querySelector: () => numberEl,
};
const controller = makeController({ itemTargets: [item] });
let requestBody: URLSearchParams | undefined;
globalThis.fetch = ((_url: string, init: RequestInit) => {
requestBody = init.body as URLSearchParams;
return Promise.resolve({ ok: true } as Response);
}) as typeof fetch;
await controller._persistOrder();
assertEquals(controller._locked, false);
assertEquals(
controller.statusTarget.classList.contains('text-bg-success'),
true,
);
assertEquals(numberEl.textContent, '1');
assertEquals(requestBody?.get('_token'), 'csrf-token');
assertEquals(requestBody?.get('ordering[]'), '42');
});
Deno.test('_persistOrder retries once, then locks and shows an error after repeated failure', async () => {
const controller = makeController();
let calls = 0;
globalThis.fetch = (() => {
calls++;
return Promise.resolve({ ok: false } as Response);
}) as typeof fetch;
await controller._persistOrder();
assertEquals(calls, 2);
assertEquals(controller._locked, true);
assertEquals(
controller.statusTarget.classList.contains('text-bg-danger'),
true,
);
});
Deno.test('_persistOrder treats a network error the same as a failed response', async () => {
const controller = makeController();
globalThis.fetch =
(() => Promise.reject(new Error('network down'))) as typeof fetch;
await controller._persistOrder();
assertEquals(controller._locked, true);
assertEquals(
controller.statusTarget.classList.contains('text-bg-danger'),
true,
);
});
+28
View File
@@ -0,0 +1,28 @@
import {Controller} from '@hotwired/stimulus';
import {Tooltip, Modal} from 'bootstrap';
export default class extends Controller {
static targets = ['clearModal', 'deleteModal'];
connect() {
this.tooltips = [];
const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].forEach(tooltipTriggerEl => {
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
});
}
disconnect() {
this.tooltips.forEach(tooltip => tooltip.dispose());
}
clearQuiz() {
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
modal.show();
}
deleteQuiz() {
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
modal.show();
}
}
-35
View File
@@ -1,35 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { Modal, Tooltip } from 'bootstrap';
export default class extends Controller {
static targets = ['clearModal', 'deleteModal'];
declare readonly clearModalTarget: HTMLElement;
declare readonly deleteModalTarget: HTMLElement;
tooltips: Tooltip[] = [];
connect(): void {
this.tooltips = [];
const tooltipTriggerList = this.element.querySelectorAll(
'[data-bs-toggle="tooltip"]',
);
[...tooltipTriggerList].forEach((tooltipTriggerEl) => {
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
});
}
disconnect(): void {
this.tooltips.forEach((tooltip) => tooltip.dispose());
}
clearQuiz(): void {
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
modal.show();
}
deleteQuiz(): void {
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
modal.show();
}
}
@@ -0,0 +1,81 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
@@ -1,129 +0,0 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
interface TurboSubmitEventDetail {
formSubmission: {
formElement: HTMLFormElement;
fetchRequest: { headers: Record<string, string> };
};
}
const CSRF_FIELD_SELECTOR =
'input[data-controller="csrf-protection"], input[name="_csrf_token"]';
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target as HTMLFormElement);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
const h = generateCsrfHeaders(detail.formSubmission.formElement);
Object.keys(h).forEach(function (k) {
detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
removeCsrfToken(detail.formSubmission.formElement);
});
export function generateCsrfToken(formElement: HTMLFormElement): void {
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute(
'data-csrf-protection-cookie-value',
csrfCookie = csrfToken,
);
csrfField.defaultValue = csrfToken = btoa(
String.fromCharCode.apply(
null,
Array.from(
(window.crypto ||
(window as unknown as { msCrypto: Crypto }).msCrypto)
.getRandomValues(new Uint8Array(18)),
),
),
);
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie +
'; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:'
? '__Host-' + cookie + '; secure'
: cookie;
}
}
export function generateCsrfHeaders(
formElement: HTMLFormElement,
): Record<string, string> {
const headers: Record<string, string> = {};
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
if (
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
) {
headers[String(csrfCookie)] = csrfField.value;
}
return headers;
}
export function removeCsrfToken(formElement: HTMLFormElement): void {
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
if (
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
) {
const cookie = String(csrfCookie) + '_' + csrfField.value +
'=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:'
? '__Host-' + cookie + '; secure'
: cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
@@ -1,119 +0,0 @@
import { assertEquals, assertMatch } from '@std/assert';
// The module under test registers `document.addEventListener(...)` calls at
// import time and reads `window.location`/`window.crypto`, so a minimal
// `document`/`window` stub must exist in the global scope *before* the module
// is imported. Deno has no browser DOM, and the actual surface these
// functions touch on a form/input is narrow (getAttribute/setAttribute/value/
// dispatchEvent), so hand-built fakes are used instead of a full DOM polyfill.
let cookieJar = '';
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
addEventListener: () => {},
get cookie() {
return cookieJar;
},
set cookie(value: string) {
cookieJar += (cookieJar ? '; ' : '') + value;
},
};
// deno-lint-ignore no-explicit-any
(globalThis as any).window = {
location: { protocol: 'http:' },
crypto: globalThis.crypto,
};
const { generateCsrfHeaders, generateCsrfToken, removeCsrfToken } =
await import(
'./csrf_protection_controller.ts'
);
function fakeField(attributes: Record<string, string> = {}, value = '') {
const store = new Map(Object.entries(attributes));
return {
getAttribute: (name: string) => store.get(name) ?? null,
setAttribute: (name: string, val: string) => {
store.set(name, val);
},
value,
defaultValue: value,
dispatchEvent: () => true,
};
}
// deno-lint-ignore no-explicit-any
function fakeForm(field: ReturnType<typeof fakeField> | null): any {
return { querySelector: () => field };
}
Deno.test('generateCsrfToken does nothing when the form has no csrf field', () => {
cookieJar = '';
generateCsrfToken(fakeForm(null));
assertEquals(cookieJar, '');
});
Deno.test('generateCsrfToken generates a token and cookie on first use', () => {
cookieJar = '';
const field = fakeField({}, 'my_field_name');
generateCsrfToken(fakeForm(field));
assertEquals(
field.getAttribute('data-csrf-protection-cookie-value'),
'my_field_name',
);
assertMatch(field.defaultValue, /^[-_/+a-zA-Z0-9]{24,}$/);
assertMatch(cookieJar, /my_field_name_[-_/+a-zA-Z0-9]{24,}=my_field_name/);
});
Deno.test('generateCsrfToken keeps an existing valid token/cookie pair unchanged', () => {
cookieJar = '';
const token = 'a'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'existing_name',
}, token);
generateCsrfToken(fakeForm(field));
assertEquals(field.value, token);
assertMatch(cookieJar, new RegExp(`existing_name_${token}=existing_name`));
});
Deno.test('generateCsrfHeaders returns empty headers without a csrf field', () => {
assertEquals(generateCsrfHeaders(fakeForm(null)), {});
});
Deno.test('generateCsrfHeaders returns the cookie-name/token header when both are valid', () => {
const token = 'b'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, token);
assertEquals(generateCsrfHeaders(fakeForm(field)), { field_name: token });
});
Deno.test('generateCsrfHeaders omits the header when the token is too short', () => {
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, 'too-short');
assertEquals(generateCsrfHeaders(fakeForm(field)), {});
});
Deno.test('removeCsrfToken expires the cookie for a valid token/cookie pair', () => {
cookieJar = '';
const token = 'c'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, token);
removeCsrfToken(fakeForm(field));
assertMatch(cookieJar, new RegExp(`field_name_${token}=0`));
});
Deno.test('removeCsrfToken does nothing without a csrf field', () => {
cookieJar = '';
removeCsrfToken(fakeForm(null));
assertEquals(cookieJar, '');
});
@@ -0,0 +1,14 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
next() {
const currentUrl = new URL(window.location.href);
const pathParts = currentUrl.pathname.split('/');
// Remove the last segment
pathParts.pop();
// Update the pathname
currentUrl.pathname = pathParts.join('/');
// Navigate
window.location.href = currentUrl.href;
}
}
@@ -1,14 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
next(): void {
const currentUrl = new URL(window.location.href);
const pathParts = currentUrl.pathname.split('/');
// Remove the last segment
pathParts.pop();
// Update the pathname
currentUrl.pathname = pathParts.join('/');
// Navigate
window.location.href = currentUrl.href;
}
}
@@ -0,0 +1,40 @@
import {Controller} from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect() {
this.onFullscreenChange = this.onFullscreenChange.bind(this);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
this.request();
}
}
disconnect() {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
}
toggle() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request() {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange() {
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
this.syncState();
}
syncState() {
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
}
}
@@ -1,51 +0,0 @@
import { Controller } from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect(): void {
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (
sessionStorage.getItem(STORAGE_KEY) === '1' &&
!document.fullscreenElement
) {
this.request();
}
}
disconnect(): void {
document.removeEventListener(
'fullscreenchange',
this.onFullscreenChange,
);
}
toggle(): void {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request(): void {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange = (): void => {
sessionStorage.setItem(
STORAGE_KEY,
document.fullscreenElement ? '1' : '0',
);
this.syncState();
};
syncState(): void {
document.documentElement.classList.toggle(
'is-fullscreen',
Boolean(document.fullscreenElement),
);
}
}
@@ -1,75 +0,0 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`
// (see @hotwired/stimulus dist/stimulus.js), and none of this controller's methods
// touch Stimulus-specific getters (element/scope/targets), so a plain `{}` context
// is enough to construct a real instance. `document`/`sessionStorage` are stubbed
// since Deno has no browser DOM.
class FakeClassList {
classes = new Set<string>();
toggle(name: string, force?: boolean) {
const shouldAdd = force ?? !this.classes.has(name);
if (shouldAdd) this.classes.add(name);
else this.classes.delete(name);
}
contains(name: string) {
return this.classes.has(name);
}
}
const classList = new FakeClassList();
let fullscreenElement: unknown = null;
const sessionStore = new Map<string, string>();
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
documentElement: { classList },
get fullscreenElement() {
return fullscreenElement;
},
addEventListener: () => {},
removeEventListener: () => {},
};
// Deno defines a native `sessionStorage` accessor on globalThis (get/set pair), so a
// plain assignment would just call through to it instead of replacing it — redefine
// the property outright.
Object.defineProperty(globalThis, 'sessionStorage', {
value: {
getItem: (key: string) => sessionStore.get(key) ?? null,
setItem: (key: string, value: string) => sessionStore.set(key, value),
},
configurable: true,
});
const { default: FullscreenController } = await import(
'./fullscreen_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(): any {
return new FullscreenController({} as never);
}
Deno.test('syncState adds is-fullscreen when a fullscreen element is set', () => {
fullscreenElement = { tagName: 'HTML' };
makeController().syncState();
assertEquals(classList.contains('is-fullscreen'), true);
});
Deno.test('syncState removes is-fullscreen when there is no fullscreen element', () => {
fullscreenElement = null;
makeController().syncState();
assertEquals(classList.contains('is-fullscreen'), false);
});
Deno.test('onFullscreenChange persists the fullscreen state and syncs classes', () => {
fullscreenElement = { tagName: 'HTML' };
makeController().onFullscreenChange();
assertEquals(sessionStore.get('tvdt-fullscreen'), '1');
assertEquals(classList.contains('is-fullscreen'), true);
fullscreenElement = null;
makeController().onFullscreenChange();
assertEquals(sessionStore.get('tvdt-fullscreen'), '0');
assertEquals(classList.contains('is-fullscreen'), false);
});
+2 -2
View File
@@ -1,4 +1,4 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/quiz.scss';
import './stimulus.ts';
import './bootstrap.ts';
import './stimulus.js';
import './bootstrap.js';
+1 -1
View File
@@ -1,3 +1,3 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
startStimulusApp();
const app = startStimulusApp();
+6
View File
@@ -3,3 +3,9 @@
.col-result-md { width: 20%; }
.modal-content > turbo-frame { display: contents; }
.release-notes {
overflow-wrap: break-word;
> :last-child { margin-bottom: 0; }
}
-11
View File
@@ -1,11 +0,0 @@
// Ambient declarations for the parts of the AssetMapper toolchain that Deno's
// module resolution doesn't otherwise understand: CSS/SCSS side-effect imports
// (handled by AssetMapper at build time, not a JS module) and the Symfony
// stimulus-bundle loader (a local vendor/ file, not an npm package).
declare module '*.css';
declare module '*.scss';
declare module '@symfony/stimulus-bundle' {
export function startStimulusApp(): unknown;
}
+3 -1
View File
@@ -13,11 +13,11 @@
"doctrine/doctrine-bundle": "^3.2.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.2",
"league/commonmark": "^2.7",
"martin-georgiev/postgresql-for-doctrine": "^4.4",
"phpdocumentor/reflection-docblock": "^6.0.3",
"phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3.2",
"sensiolabs/typescript-bundle": "^0.2.2",
"sentry/sentry-symfony": "^5.9.0",
"stof/doctrine-extensions-bundle": "^1.15.3",
"symfony/asset": "8.1.*",
@@ -28,6 +28,7 @@
"symfony/flex": "^2.11.0",
"symfony/form": "8.1.*",
"symfony/framework-bundle": "8.1.*",
"symfony/http-client": "8.1.*",
"symfony/mailer": "8.1.*",
"symfony/object-mapper": "8.1.*",
"symfony/property-access": "8.1.*",
@@ -49,6 +50,7 @@
"thecodingmachine/safe": "^3.4.0",
"twig/extra-bundle": "^3.24.0",
"twig/intl-extra": "^3.24.0",
"twig/markdown-extra": "^3.24.0",
"twig/twig": "^3.27.1"
},
"require-dev": {
Generated
+495 -57
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cca03585c291226d36bbeabf57a08bec",
"content-hash": "519e76dd9df3bca43c3e32e347c600e7",
"packages": [
{
"name": "composer/pcre",
@@ -159,6 +159,81 @@
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
"scrutinizer/ocular": "1.6.0",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Dflydev\\DotAccessData\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dragonfly Development Inc.",
"email": "info@dflydev.com",
"homepage": "http://dflydev.com"
},
{
"name": "Beau Simensen",
"email": "beau@dflydev.com",
"homepage": "http://beausimensen.com"
},
{
"name": "Carlos Frutos",
"email": "carlos@kiwing.it",
"homepage": "https://github.com/cfrutos"
},
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com"
}
],
"description": "Given a deep data structure, access data by dot notation.",
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
"keywords": [
"access",
"data",
"dot",
"notation"
],
"support": {
"issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
"source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/collections",
"version": "2.6.0",
@@ -1652,6 +1727,195 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"league/config": "^1.1.1",
"php": "^7.4 || ^8.0",
"psr/event-dispatcher": "^1.0",
"symfony/deprecation-contracts": "^2.1 || ^3.0",
"symfony/polyfill-php80": "^1.16"
},
"require-dev": {
"cebe/markdown": "^1.0",
"commonmark/cmark": "0.31.1",
"commonmark/commonmark.js": "0.31.1",
"composer/package-versions-deprecated": "^1.8",
"embed/embed": "^4.4",
"erusev/parsedown": "^1.0",
"ext-json": "*",
"github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^2.0.0",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.9-dev"
}
},
"autoload": {
"psr-4": {
"League\\CommonMark\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
"homepage": "https://commonmark.thephpleague.com",
"keywords": [
"commonmark",
"flavored",
"gfm",
"github",
"github-flavored",
"markdown",
"md",
"parser"
],
"support": {
"docs": "https://commonmark.thephpleague.com/",
"forum": "https://github.com/thephpleague/commonmark/discussions",
"issues": "https://github.com/thephpleague/commonmark/issues",
"rss": "https://github.com/thephpleague/commonmark/releases.atom",
"source": "https://github.com/thephpleague/commonmark"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
"type": "tidelift"
}
],
"time": "2026-07-12T15:29:16+00:00"
},
{
"name": "league/config",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/config.git",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"shasum": ""
},
"require": {
"dflydev/dot-access-data": "^3.0.1",
"nette/schema": "^1.2",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.5",
"scrutinizer/ocular": "^1.8.1",
"unleashedtech/php-coding-standard": "^3.1",
"vimeo/psalm": "^4.7.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\Config\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Define configuration arrays with strict schemas and access values with dot notation",
"homepage": "https://config.thephpleague.com",
"keywords": [
"array",
"config",
"configuration",
"dot",
"dot-access",
"nested",
"schema"
],
"support": {
"docs": "https://config.thephpleague.com/",
"issues": "https://github.com/thephpleague/config/issues",
"rss": "https://github.com/thephpleague/config/releases.atom",
"source": "https://github.com/thephpleague/config"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
}
],
"time": "2022-12-11T20:36:23+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
@@ -1966,6 +2230,164 @@
],
"time": "2026-07-01T18:17:39+00:00"
},
{
"name": "nette/schema",
"version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
"php": "8.1 - 8.5"
},
"require-dev": {
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.6",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "📐 Nette Schema: validating data structures against a given Schema.",
"homepage": "https://nette.org",
"keywords": [
"config",
"nette"
],
"support": {
"issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.5"
},
"time": "2026-02-23T03:47:12+00:00"
},
{
"name": "nette/utils",
"version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
"php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"homepage": "https://nette.org",
"keywords": [
"array",
"core",
"datetime",
"images",
"json",
"nette",
"paginator",
"password",
"slugify",
"string",
"unicode",
"utf-8",
"utility",
"validation"
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.4"
},
"time": "2026-05-11T20:49:54+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -2751,62 +3173,6 @@
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "sensiolabs/typescript-bundle",
"version": "v0.2.2",
"source": {
"type": "git",
"url": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle.git",
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sensiolabs/AssetMapperTypeScriptBundle/zipball/b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
"symfony/console": "^6.3|^7.0|^8.0",
"symfony/http-client": "^6.3|^7.0|^8.0",
"symfony/process": "^6.3|^7.0|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/phpunit": "^10.5",
"symfony/filesystem": "^6.3|^7.0|^8.0",
"symfony/framework-bundle": "^6.3|^7.0|^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Sensiolabs\\TypeScriptBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maelan LE BORGNE",
"homepage": "https://github.com/maelanleborgne"
}
],
"description": "TypeScript support for Symfony + AssetMapper",
"homepage": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle",
"keywords": [
"asset-mapper",
"typescript"
],
"support": {
"issues": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/issues",
"source": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/tree/v0.2.2"
},
"time": "2025-08-28T10:10:44+00:00"
},
{
"name": "sentry/sentry",
"version": "4.29.0",
@@ -8767,6 +9133,78 @@
],
"time": "2026-05-19T20:44:48+00:00"
},
{
"name": "twig/markdown-extra",
"version": "v3.28.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/markdown-extra.git",
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"twig/twig": "^3.13|^4.0"
},
"require-dev": {
"erusev/parsedown": "dev-master as 1.x-dev",
"league/commonmark": "^2.7",
"league/html-to-markdown": "^4.8|^5.0",
"michelf/php-markdown": "^1.8|^2.0",
"symfony/phpunit-bridge": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Twig\\Extra\\Markdown\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Twig extension for Markdown",
"homepage": "https://twig.symfony.com",
"keywords": [
"html",
"markdown",
"twig"
],
"support": {
"source": "https://github.com/twigphp/markdown-extra/tree/v3.28.0"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2026-06-05T19:47:22+00:00"
},
{
"name": "twig/twig",
"version": "v3.28.0",
-2
View File
@@ -6,7 +6,6 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Sensiolabs\TypeScriptBundle\SensiolabsTypeScriptBundle;
use Sentry\SentryBundle\SentryBundle;
use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
@@ -39,5 +38,4 @@ return [
DAMADoctrineTestBundle::class => ['test' => true],
StofDoctrineExtensionsBundle::class => ['all' => true],
SymfonyCastsResetPasswordBundle::class => ['all' => true],
SensiolabsTypeScriptBundle::class => ['all' => true],
];
-4
View File
@@ -6,12 +6,8 @@ framework:
excluded_patterns:
- '*/assets/styles/_*.scss'
- '*/assets/styles/**/_*.scss'
- '*/assets/**/*_test.ts'
- '*/assets/types/*.d.ts'
missing_import_mode: strict
sensiolabs_typescript:
source_dir: ['%kernel.project_dir%/assets']
when@prod:
framework:
+1
View File
@@ -30,6 +30,7 @@ security:
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/backoffice/releases$, roles: PUBLIC_ACCESS }
- { path: ^/backoffice, roles: IS_AUTHENTICATED }
when@test:
+8
View File
@@ -0,0 +1,8 @@
twig_extra:
commonmark:
allow_unsafe_links: false
html_input: escape
services:
League\CommonMark\Extension\Autolink\AutolinkExtension:
tags: ['twig.markdown.league_extension']
+1 -12
View File
@@ -1271,7 +1271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false
* },
* markdown?: bool|array{
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* },
* intl?: bool|array{
* enabled?: bool|Param, // Default: true
@@ -1496,13 +1496,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600
* enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true
* }
* @psalm-type SensiolabsTypescriptConfig = array{
* source_dir?: list<scalar|Param|null>,
* binary_download_dir?: scalar|Param|null, // The directory where the SWC binary will be downloaded // Default: "%kernel.project_dir%/var"
* swc_binary?: scalar|Param|null, // The SWC binary to use // Default: null
* swc_config_file?: scalar|Param|null, // Path to .swcrc configuration file to use // Default: "%kernel.project_dir%/.swcrc"
* swc_version?: scalar|Param|null, // The SWC version to use // Default: "v1.3.92"
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1519,7 +1512,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1539,7 +1531,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1558,7 +1549,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1578,7 +1568,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* dama_doctrine_test?: DamaDoctrineTestConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,
-32
View File
@@ -1,32 +0,0 @@
{
"compilerOptions": {
"lib": ["deno.window", "dom", "dom.iterable"],
"strict": true,
"noImplicitOverride": false,
"types": ["./assets/types/global.d.ts"]
},
"imports": {
"@hotwired/stimulus": "npm:@hotwired/stimulus@^3.2.2",
"@hotwired/turbo": "npm:@hotwired/turbo@^8.0.23",
"bootstrap": "npm:bootstrap@^5.3.8",
"@sentry/browser": "npm:@sentry/browser@^10.63.0",
"@std/assert": "jsr:@std/assert@^1.0.19"
},
"fmt": {
"include": ["assets/"],
"exclude": ["assets/vendor/", "assets/styles/", "assets/img/"],
"singleQuote": true,
"indentWidth": 4
},
"lint": {
"include": ["assets/"],
"exclude": ["assets/vendor/"],
"rules": {
"exclude": ["no-window", "no-window-prefix"]
}
},
"test": {
"include": ["assets/"],
"exclude": ["assets/vendor/"]
}
}
Generated
-96
View File
@@ -1,96 +0,0 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"npm:@hotwired/stimulus@^3.2.2": "3.2.2",
"npm:@hotwired/turbo@^8.0.23": "8.0.23",
"npm:@sentry/browser@^10.63.0": "10.65.0",
"npm:bootstrap@^5.3.8": "5.3.8_@popperjs+core@2.11.8"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
}
},
"npm": {
"@hotwired/stimulus@3.2.2": {
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="
},
"@hotwired/turbo@8.0.23": {
"integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="
},
"@popperjs/core@2.11.8": {
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
},
"@sentry/browser-utils@10.65.0": {
"integrity": "sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==",
"dependencies": [
"@sentry/conventions",
"@sentry/core"
]
},
"@sentry/browser@10.65.0": {
"integrity": "sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==",
"dependencies": [
"@sentry/browser-utils",
"@sentry/conventions",
"@sentry/core",
"@sentry/feedback",
"@sentry/replay",
"@sentry/replay-canvas"
]
},
"@sentry/conventions@0.15.1": {
"integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA=="
},
"@sentry/core@10.65.0": {
"integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==",
"dependencies": [
"@sentry/conventions"
]
},
"@sentry/feedback@10.65.0": {
"integrity": "sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==",
"dependencies": [
"@sentry/core"
]
},
"@sentry/replay-canvas@10.65.0": {
"integrity": "sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==",
"dependencies": [
"@sentry/core",
"@sentry/replay"
]
},
"@sentry/replay@10.65.0": {
"integrity": "sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==",
"dependencies": [
"@sentry/browser-utils",
"@sentry/core"
]
},
"bootstrap@5.3.8_@popperjs+core@2.11.8": {
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"dependencies": [
"@popperjs/core"
]
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@^1.0.19",
"npm:@hotwired/stimulus@^3.2.2",
"npm:@hotwired/turbo@^8.0.23",
"npm:@sentry/browser@^10.63.0",
"npm:bootstrap@^5.3.8"
]
}
}
+4
View File
@@ -33,6 +33,10 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi
fi
# var/ is a Docker volume that survives redeploys, so cache.app (e.g. the GitHub releases cache)
# can carry stale entries from the previous image into the new one unless cleared here.
php bin/console cache:pool:clear cache.app
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
+2 -2
View File
@@ -25,8 +25,8 @@ declare(strict_types=1);
* }>
*/
return [
'quiz' => ['path' => './assets/quiz.ts', 'entrypoint' => true],
'backoffice' => ['path' => './assets/backoffice.ts', 'entrypoint' => true],
'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true],
'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true],
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
'bootstrap' => ['version' => '5.3.8'],
'@popperjs/core' => ['version' => '2.11.8'],
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Tvdt\Controller\AbstractController;
use Tvdt\Service\GitHubReleasesService;
final class ReleasesController extends AbstractController
{
public function __construct(
private readonly GitHubReleasesService $gitHubReleasesService,
) {}
#[Route('/backoffice/releases', name: 'tvdt_backoffice_releases', methods: ['GET'])]
public function index(): Response
{
return $this->render('backoffice/releases/_frame.html.twig', [
'releases' => $this->gitHubReleasesService->getReleases(),
]);
}
}
+14 -2
View File
@@ -223,7 +223,7 @@ class DataExportService
}
}
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave (bold when correct). */
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
{
/** @var list<Question> $questions */
@@ -240,11 +240,14 @@ class DataExportService
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
$answersByCandidateAndQuestion = [];
/** @var array<string, array<string, bool>> $correctnessByCandidateAndQuestion */
$correctnessByCandidateAndQuestion = [];
foreach ($questions as $question) {
foreach ($question->answers as $answer) {
foreach ($answer->givenAnswers as $givenAnswer) {
$candidateId = $givenAnswer->candidate->id->toString();
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
$correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->isRightAnswer;
}
}
}
@@ -252,13 +255,22 @@ class DataExportService
$row = 2;
foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate;
$candidateId = $candidate->id->toString();
$line = [$candidate->name];
foreach ($questions as $question) {
$line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? '';
$line[] = $answersByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? '';
}
$sheet->fromArray($line, null, 'A'.$row);
foreach ($questions as $columnIndex => $question) {
if ($correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? false) {
$column = Coordinate::stringFromColumnIndex(2 + $columnIndex);
$sheet->getStyle($column.$row)->getFont()->setBold(true);
}
}
++$row;
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Psr\Cache\InvalidArgumentException;
use Safe\DateTimeImmutable;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class GitHubReleasesService
{
private const string RELEASES_URL = 'https://api.github.com/repos/MarijnDoeve/TijdVoorDeTest/releases';
public function __construct(
private HttpClientInterface $httpClient,
private CacheInterface $cache,
) {}
/**
* @throws InvalidArgumentException
*
* @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}>
*/
public function getReleases(): array
{
return $this->cache->get('github_releases', $this->fetchReleases(...));
}
/** @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}> */
private function fetchReleases(ItemInterface $item, bool &$save): array
{
try {
$response = $this->httpClient->request('GET', self::RELEASES_URL, [
'timeout' => 5,
'headers' => [
'Accept' => 'application/vnd.github+json',
'User-Agent' => 'TijdVoorDeTest',
],
]);
/** @var list<array{tag_name: string, name: ?string, published_at: ?string, body: ?string, html_url: string}> $releases */
$releases = $response->toArray();
} catch (ExceptionInterface) {
$save = false;
return [];
}
$item->expiresAfter(3600);
usort($releases, static fn (array $a, array $b): int => ($b['published_at'] ?? '') <=> ($a['published_at'] ?? ''));
return array_map(static fn (array $release): array => [
'tagName' => $release['tag_name'],
'name' => $release['name'] ?: $release['tag_name'],
'publishedAt' => $release['published_at'] ? new DateTimeImmutable($release['published_at']) : null,
'body' => (string) $release['body'],
'url' => $release['html_url'],
], $releases);
}
}
-3
View File
@@ -98,9 +98,6 @@
"tests/bootstrap.php"
]
},
"sensiolabs/typescript-bundle": {
"version": "v0.2.2"
},
"sentry/sentry-symfony": {
"version": "5.8",
"recipe": {
+36 -17
View File
@@ -1,18 +1,18 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ path('tvdt_backoffice_index') }}">Tijd voor de test</a>
{% if is_granted('IS_AUTHENTICATED') %}
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a>
@@ -21,8 +21,27 @@
<a class="nav-link"
href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a>
</li>
</ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0">
{% endif %}
</ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0">
<li class="nav-item" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
<button type="button" class="nav-link border-0 bg-transparent"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_releases') }}"
data-modal-title="{{ 'Releases'|trans }}">{{ 'Releases'|trans }}</button>
<div class="modal fade" tabindex="-1" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="releasesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<turbo-frame id="releases-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
</li>
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
@@ -31,8 +50,8 @@
<a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
</li>
</ul>
</div>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
@@ -0,0 +1,40 @@
<turbo-frame id="releases-modal-frame">
<div class="modal-header">
<h1 class="modal-title fs-5" id="releasesModalLabel">
{{ 'Releases'|trans }}
{% if releases[0] is defined %}
<span class="text-muted fs-6 ms-2">{{ 'Current version'|trans }}: {{ releases[0].tagName }}</span>
{% endif %}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if releases is empty %}
<p class="text-muted mb-0">{{ 'Could not load releases from GitHub.'|trans }}</p>
{% else %}
<div class="accordion" id="releasesAccordion">
{% for release in releases %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button{% if not loop.first %} collapsed{% endif %}" type="button"
data-bs-toggle="collapse" data-bs-target="#release-{{ loop.index }}">
{{ release.name }}
{% if release.publishedAt %}
<span class="text-muted ms-2 small">{{ release.publishedAt|date('d-m-Y') }}</span>
{% endif %}
</button>
</h2>
<div id="release-{{ loop.index }}"
class="accordion-collapse collapse{% if loop.first %} show{% endif %}"
data-bs-parent="#releasesAccordion">
<div class="accordion-body">
<div class="release-notes">{{ release.body|markdown_to_html }}</div>
<a href="{{ release.url }}" target="_blank" rel="noopener noreferrer" class="small">{{ 'View on GitHub'|trans }}</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</turbo-frame>
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\ReleasesController;
use Tvdt\Service\GitHubReleasesService;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(ReleasesController::class)]
final class ReleasesControllerTest extends AbstractControllerWebTestCase
{
public function testReleasesFrameRendersReleaseNotes(): void
{
$this->loginAs('user2@example.org');
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'v0.8.0');
self::assertSelectorTextContains('body', 'Some release notes');
self::assertSelectorTextContains('#releasesModalLabel', 'Huidige versie: v0.8.0');
}
public function testReleasesFrameIsAccessibleWithoutAuthentication(): void
{
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
}
private function mockReleasesService(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
self::getContainer()->set(GitHubReleasesService::class, new GitHubReleasesService($httpClient, new ArrayAdapter()));
}
}
-18
View File
@@ -19,24 +19,6 @@ final class LoginControllerTest extends AbstractControllerWebTestCase
self::assertSelectorExists('form');
}
public function testNavbarTogglerHasNoDeadTargetWhenNotAuthenticated(): void
{
$this->client->request(Request::METHOD_GET, '/login');
$crawler = $this->client->getCrawler();
$toggler = $crawler->filter('.navbar-toggler');
if (0 === $toggler->count()) {
self::assertSelectorNotExists('#navbarSupportedContent');
return;
}
$target = $toggler->attr('data-bs-target');
$this->assertNotNull($target);
self::assertSelectorExists($target);
}
public function testLoginRedirectsToBackofficeWhenAlreadyAuthenticated(): void
{
$this->loginAs('test@example.org');
+45
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -201,6 +202,50 @@ final class DataExportServiceTest extends DatabaseTestCase
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
}
public function testRawAnswersSheetBoldsCorrectAnswersOnly(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
/** @var Question $firstQuestion */
$firstQuestion = $quiz->questions->first();
$correctAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => $answer->isRightAnswer)->first();
$wrongAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => !$answer->isRightAnswer)->first();
$this->assertInstanceOf(Answer::class, $correctAnswer);
$this->assertInstanceOf(Answer::class, $wrongAnswer);
$candidateWithCorrectAnswer = $this->getCandidateBySeasonAndName($season, 'Claudia');
$candidateWithWrongAnswer = $this->getCandidateBySeasonAndName($season, 'Eelco');
$this->quizCandidateRepository->createIfNotExist($quiz, $candidateWithCorrectAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidateWithWrongAnswer);
$this->entityManager->persist(new GivenAnswer($candidateWithCorrectAnswer, $quiz, $correctAnswer));
$this->entityManager->persist(new GivenAnswer($candidateWithWrongAnswer, $quiz, $wrongAnswer));
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$sheet = $this->loadSheet($quizContent, 'Raw answers');
$rows = $sheet->toArray();
$header = $rows[0];
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
$this->assertIsInt($questionColumnIndex);
$column = Coordinate::stringFromColumnIndex($questionColumnIndex + 1);
$candidateNames = array_column(\array_slice($rows, 1), 0);
$correctRowNumber = 2 + array_search('Claudia', $candidateNames, true);
$wrongRowNumber = 2 + array_search('Eelco', $candidateNames, true);
$this->assertTrue($sheet->getStyle($column.$correctRowNumber)->getFont()->getBold(), 'Expected the correct answer to be bold');
$this->assertFalse($sheet->getStyle($column.$wrongRowNumber)->getFont()->getBold(), 'Expected the wrong answer to not be bold');
}
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
{
$season = $this->getSeasonByCode('krtek');
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Safe\DateTimeImmutable;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Tvdt\Service\GitHubReleasesService;
#[CoversClass(GitHubReleasesService::class)]
final class GitHubReleasesServiceTest extends TestCase
{
public function testGetReleasesMapsGitHubResponse(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => "## Added\n- Something new",
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertEquals([
'tagName' => 'v0.8.0',
'name' => 'v0.8.0',
'publishedAt' => new DateTimeImmutable('2026-07-12T10:00:00Z'),
'body' => "## Added\n- Something new",
'url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
], $releases[0]);
}
public function testGetReleasesReturnsEmptyArrayOnHttpFailure(): void
{
$httpClient = new MockHttpClient(static fn (): MockResponse => new MockResponse('', ['http_code' => 500]));
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$this->assertSame([], $subject->getReleases());
}
public function testHttpFailureIsNotCached(): void
{
$requestCount = 0;
$httpClient = new MockHttpClient(static function () use (&$requestCount): MockResponse {
++$requestCount;
return new MockResponse('', ['http_code' => 500]);
});
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$subject->getReleases();
$subject->getReleases();
$this->assertSame(2, $requestCount);
}
public function testGetReleasesSortsNewestFirst(): void
{
$body = json_encode([
[
'tag_name' => 'v0.7.0',
'name' => 'v0.7.0',
'published_at' => '2026-07-10T10:00:00Z',
'body' => 'Older release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.7.0',
],
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Newer release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertSame('v0.8.0', $releases[0]['tagName']);
$this->assertSame('v0.7.0', $releases[1]['tagName']);
}
}
+16
View File
@@ -241,6 +241,10 @@
<source>Could not find candidate with name {name} in elimination.</source>
<target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target>
</trans-unit>
<trans-unit id="HL5QYZd" resname="Could not load releases from GitHub.">
<source>Could not load releases from GitHub.</source>
<target>Kan releases niet laden van GitHub.</target>
</trans-unit>
<trans-unit id="0DvmToq" resname="Create a season">
<source>Create a season</source>
<target>Maak een seizoen aan</target>
@@ -265,6 +269,10 @@
<source>Current password</source>
<target>Huidig wachtwoord</target>
</trans-unit>
<trans-unit id="xreXHN5" resname="Current version">
<source>Current version</source>
<target>Huidige versie</target>
</trans-unit>
<trans-unit id="PkrbQOH" resname="Cyan">
<source>Cyan</source>
<target>Cyaan</target>
@@ -745,6 +753,10 @@
<source>Register</source>
<target>Registreren</target>
</trans-unit>
<trans-unit id="3s37xTt" resname="Releases">
<source>Releases</source>
<target>Releases</target>
</trans-unit>
<trans-unit id="WevL4T_" resname="Remember me">
<source>Remember me</source>
<target>Onthoud mij</target>
@@ -977,6 +989,10 @@
<source>View</source>
<target>Bekijken</target>
</trans-unit>
<trans-unit id="a1.g0sm" resname="View on GitHub">
<source>View on GitHub</source>
<target>Bekijk op GitHub</target>
</trans-unit>
<trans-unit id="JWRtx_o" resname="White">
<source>White</source>
<target>Wit</target>