Files
Marijn 404c0dcc26 Summer cleanup: XLSX export, WIDM-style quiz UI, CSS fixes (#162)
* Add CLAUDE.md, replace Makefile with Justfile, remove .junie

- Add CLAUDE.md with project overview, commands, architecture, and domain entity docs
- Remove Makefile in favour of the existing Justfile
- Remove .junie/AGENTS.md (knowledge transferred to CLAUDE.md)
- Update .gitignore: drop .junie/ entries, add .claude/settings.local.json
- Minor doc fixes in config/reference.php (typo, type correction)

* Clean up templates and CSS

- season.html.twig: remove dead empty column, drop redundant flex-row
- tab_overview.html.twig: extract Twig macro for confirm modals, fix duplicate aria-labelledby IDs
- tab_result.html.twig: remove dead comment, replace inline widths with CSS classes, simplify nested row/col forms to d-flex gap-1
- backoffice.scss: add col-result-xs/sm/md column width classes
- quiz.scss: replace broken display:grid + justify-self:center with flexbox centering

* Implement quizToXlsx() export and add export button

- QuizSpreadsheetService: implement quizToXlsx() as the inverse of
  fillQuizFromArray() — writes quiz questions and answers to XLSX using
  the same column layout as the import template
- BackofficeController: add exportQuiz() action at GET /backoffice/quiz/{quiz}/export
- tab_overview.html.twig: add Export to XLSX button in Quick actions

* Add unit tests for QuizSpreadsheetService

7 tests covering generateTemplate(), quizToXlsx(), and xlsxToQuiz():
- valid XLSX output and MIME type
- template without example reimports as empty
- template example data survives a reimport
- round-trip (export → reimport) preserves questions, answers, and correct flags
- empty quiz exports and reimports cleanly
- invalid MIME type throws InvalidArgumentException
- question with no answers throws SpreadsheetDataException with error list

* Fix quiz page vertical centering regression

The CSS cleanup broke vertical centering: flex on body causes main to
stretch full-width; place-items:center on a grid body only centers
items within their auto-sized track (not the track within body).

Fix: move background/color to html (full-viewport grid that centers
body), give body height:100% + display:grid + align-content:center
(centers the content track within full-height body) + justify-self:center
(shrink-wraps body width). Matches production behavior exactly.

* Improve quiz page layouts: WIDM-style answers and responsive centering

- Add green square answer buttons styled after the TV show
- Two-column answer grid for 6+ answers, single column on mobile
- fit-content centering for question pages so block matches question width
- Narrow fixed-width centering for form pages (enter name, select season)

* Use HeaderUtils::makeDisposition() for safe Content-Disposition filename

* Fix quizToXlsx to support unlimited answers and add header count tests

- Replace hardcoded 6-column arrays with dynamic Coordinate arithmetic
- Write data rows first to determine max answer count, write headers last
- Replace try/catch ErrorException in fillQuizFromArray with array_key_exists
- Add data-provider test covering 2, 6, 7, and 10 answers
- Add cross-question max-header and 7-answer round-trip tests

* Fix Sass healthcheck

* Improve quiz layout: add fixed topbar, include navigation, and clean up unused elements

- Add `.quiz-topbar` with fixed positioning and spacing in `quiz.scss`
- Update `base.html.twig` to include `quiz/nav.html.twig` in a new `nav` block
- Remove unused "Manage Quiz" button from `select_season.html.twig`

* Refactor generateTemplate to reuse quizToXlsx and add second example question

- generateTemplate now builds an in-memory Quiz entity and delegates to
  quizToXlsx, eliminating duplicate spreadsheet-building logic
- Adds a second example question "Wie is de mol?" with 10 Dutch names
  (5 male, 5 female) to better illustrate the import format
- Updates tests to assert both example questions and adds a test for the
  blank-row halt behaviour in fillQuizFromArray (achieving 100% coverage)

* Move PHPUnit cache to /tmp to avoid writing into the mounted volume

* Update src/Service/QuizSpreadsheetService.php

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-02 20:25:02 +00:00

114 lines
5.2 KiB
Twig

{% macro confirm_modal(id, target, body, formAction, csrfToken) %}
<div class="modal fade" id="{{ id }}" data-bs-backdrop="static"
tabindex="-1"
data-bo--quiz-target="{{ target }}"
aria-labelledby="{{ id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="{{ id }}Label">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">{{ body }}</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ formAction }}" method="POST">
<input type="hidden" name="_token" value="{{ csrfToken }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
{% endmacro %}
<div data-controller="bo--quiz">
<h4 class="mb-3">{{ 'Quick actions'|trans }}</h4>
<div class="mb-3 btn-group">
{% if quiz is same as (season.activeQuiz) %}
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">
{{ 'Deactivate Quiz'|trans }}
</button>
</form>
{% else %}
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
<button type="submit" class="btn btn-primary rounded-0 rounded-start">
{{ 'Make active'|trans }}
</button>
</form>
{% endif %}
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
{{ 'Clear Quiz...'|trans }}
</button>
<button class="btn btn-danger rounded-0 rounded-end" data-action="click->bo--quiz#deleteQuiz">
{{ 'Delete Quiz...'|trans }}
</button>
</div>
<a class="btn btn-outline-secondary mb-3"
href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}">
{{ 'Export to XLSX'|trans }}
</a>
<h4 class="mb-3">{{ 'Questions'|trans }}</h4>
<div class="accordion">
{%~ for question in quiz.questions ~%}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#question-{{ loop.index0 }}"
aria-controls="question-{{ loop.index0 }}">
{% set questionError = questionErrors[question.id.toString] ?? null %}
<span
class="badge rounded-pill me-2{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"{% if questionError %} data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
{{~ loop.index -}}. {{ question.question -}}
</button>
</h2>
<div id="question-{{ loop.index0 }}"
class="accordion-collapse collapse">
<div class="accordion-body">
<ul>
{%~ for answer in question.answers %}
<li{% if answer.isRightAnswer %} class="text-decoration-underline"{% endif %}>
{{ answer.text }}
{% if answer.candidates|length > 0 %}
<small class="text-muted">
({{ answer.candidates|map(c => c.name)|join(', ') }})
</small>
{% endif %}
</li>
{%~ else %}
{{ 'There are no answers for this question'|trans -}}
{%~ endfor %}
</ul>
</div>
</div>
</div>
{% else %}
{{ 'EMPTY'|trans }}
{% endfor %}
</div>
{{ _self.confirm_modal(
'clearQuizModal',
'clearModal',
'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans,
path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}),
csrf_token('clear_quiz'),
) }}
{{ _self.confirm_modal(
'deleteQuizModal',
'deleteModal',
'Are you sure you want to delete this quiz?'|trans,
path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}),
csrf_token('delete_quiz'),
) }}
</div>