Compare commits

..

4 Commits

Author SHA1 Message Date
Marijn ce415217bf feat: show a hint for the candidates textarea and gate translations in CI
Add a help hint on the add-candidates form explaining one candidate per
line, and translate it plus a few strings from the earlier reset-progress
feature that had been missed. Also add a CI step that re-runs
translation:extract and fails the build if it produces uncommitted
changes, so untranslated/stale strings can't slip through review again.
2026-07-10 23:50:44 +02:00
Marijn 5cfeeb57f0 feat: open candidate edit/add forms in modals with dirty-aware dismissal
Rename-candidate modal always blocked backdrop-click dismissal via a
hardcoded static backdrop; wire it into the existing bo--modal
controller so outside clicks/Escape only get blocked once the name
field has actually been edited. Also move "Add Candidate" from a
full-page navigation into the same turbo-frame modal pattern already
used for editing quiz questions, keeping the full-page form as a
fallback for non-JS/turbo requests.
2026-07-10 23:44:41 +02:00
Marijn 36238f45bb fix: wrap candidate progress reset in a transaction
Avoids leaving a candidate half-reset (answers deleted but started
still set) if the second flush fails. Addresses CodeRabbit review
comment on PR #204.
2026-07-10 19:31:47 +02:00
Marijn b5997adef6 feat: add reset progress action for a candidate's quiz attempt
Admins previously had no way to let a candidate retake a quiz once
started. Adds a backoffice action that clears a candidate's given
answers and start timestamp for a quiz, closes #20.
2026-07-10 19:14:15 +02:00
16 changed files with 219 additions and 81 deletions
+10
View File
@@ -95,6 +95,15 @@ jobs:
id: rector
continue-on-error: true
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
- name: Translations
id: translations
continue-on-error: true
run: |
docker compose exec -T php bin/console translation:extract --force --format=xliff --sort=asc nl
if ! git diff --exit-code -- translations; then
echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
exit 1
fi
- name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost
- name: Assert all checks passed
@@ -113,6 +122,7 @@ jobs:
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
check "PHPStan" "${{ steps.phpstan.outcome }}"
check "Rector" "${{ steps.rector.outcome }}"
check "Translations" "${{ steps.translations.outcome }}"
exit $failed
tests:
+1
View File
@@ -306,6 +306,7 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
- Twig-CS-Fixer style check
- PHPStan static analysis
- Rector dry-run
- Translation extraction check (fails if `translation:extract` produces uncommitted changes)
3. **Integration Tests**:
- Docker image build and start services
- Database creation and migration
Generated
+6 -6
View File
@@ -9408,16 +9408,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.13",
"version": "v3.95.12",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "ea941114a002eb5e5876f190223deec1066c482c"
"reference": "b1b9055997a98dce3c2338e884626e718a25a923"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c",
"reference": "ea941114a002eb5e5876f190223deec1066c482c",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923",
"shasum": ""
},
"require": {
@@ -9501,7 +9501,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12"
},
"funding": [
{
@@ -9509,7 +9509,7 @@
"type": "github"
}
],
"time": "2026-07-10T09:23:21+00:00"
"time": "2026-07-07T13:29:36+00:00"
},
{
"name": "myclabs/deep-copy",
@@ -25,6 +25,7 @@ use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Exception\ErrorClearingQuizException;
use Tvdt\Repository\GivenAnswerRepository;
use Tvdt\Repository\QuizCandidateRepository;
use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter;
@@ -37,6 +38,7 @@ class QuizController extends AbstractController
private readonly QuizRepository $quizRepository,
private readonly TranslatorInterface $translator,
private readonly QuizCandidateRepository $quizCandidateRepository,
private readonly GivenAnswerRepository $givenAnswerRepository,
private readonly EntityManagerInterface $em,
) {}
@@ -397,6 +399,26 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsCsrfTokenValid('reset_candidate_progress')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/candidate/{candidate}/reset',
name: 'tvdt_backoffice_reset_candidate_progress',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse
{
$this->em->wrapInTransaction(function () use ($quiz, $candidate): void {
$this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate);
$this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate);
});
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset'));
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
/**
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
*
+12 -2
View File
@@ -129,6 +129,8 @@ class SeasonController extends AbstractController
)]
public function addCandidates(Season $season, Request $request): Response
{
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request);
@@ -140,10 +142,18 @@ class SeasonController extends AbstractController
$this->em->flush();
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
if ($isTurboFrame) {
return new Response('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>');
}
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$template = $isTurboFrame
? 'backoffice/season/_add_candidates_frame.html.twig'
: 'backoffice/season_add_candidates.html.twig';
return $this->render($template, ['form' => $form, 'season' => $season]);
}
#[IsCsrfTokenValid('rename_candidate')]
+3 -1
View File
@@ -19,7 +19,9 @@ class AddCandidatesFormType extends AbstractType
{
$builder
->add('candidates', TextareaType::class, [
'label' => $this->translator->trans('Candidates'), 'translation_domain' => false,
'label' => $this->translator->trans('Candidates'),
'help' => $this->translator->trans('One candidate per line'),
'translation_domain' => false,
])
;
}
+13
View File
@@ -6,7 +6,9 @@ namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Quiz;
/** @extends ServiceEntityRepository<GivenAnswer> */
class GivenAnswerRepository extends ServiceEntityRepository
@@ -15,4 +17,15 @@ class GivenAnswerRepository extends ServiceEntityRepository
{
parent::__construct($registry, GivenAnswer::class);
}
public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void
{
$givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]);
foreach ($givenAnswers as $givenAnswer) {
$this->getEntityManager()->remove($givenAnswer);
}
$this->getEntityManager()->flush();
}
}
@@ -68,4 +68,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
$quizCandidate->penaltySeconds = $penalty;
$this->getEntityManager()->flush();
}
public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void
{
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
if (!$quizCandidate instanceof QuizCandidate) {
return;
}
$quizCandidate->started = null;
$this->getEntityManager()->flush();
}
}
+3 -19
View File
@@ -263,7 +263,7 @@ class DataExportService
}
$lastColumnIndex = 1 + \count($questions);
foreach ($this->columnLetters($lastColumnIndex) as $column) {
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
@@ -297,7 +297,7 @@ class DataExportService
++$row;
}
foreach ($this->columnLetters(2 + \count($candidates)) as $column) {
foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
@@ -405,7 +405,7 @@ class DataExportService
}
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
foreach ($this->columnLetters($lastColumnIndex) as $column) {
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
@@ -426,22 +426,6 @@ class DataExportService
}
}
/**
* Column letters from 'A' up to and including the given 1-based column index.
* Unlike range('A', ...), this works past 'Z' (e.g. index 27 => 'AA').
*
* @return list<string>
*/
private function columnLetters(int $lastColumnIndex): array
{
$letters = [];
for ($columnIndex = 1; $columnIndex <= $lastColumnIndex; ++$columnIndex) {
$letters[] = Coordinate::stringFromColumnIndex($columnIndex);
}
return $letters;
}
/** @throws FilesystemException */
private function writeToTempFile(Spreadsheet $spreadsheet): string
{
@@ -37,7 +37,7 @@
{% endif %}
</td>
<td>
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST">
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">
{% if quizCandidate == null or quizCandidate.active %}
@@ -47,6 +47,12 @@
{% endif %}
</button>
</form>
{% if quizCandidate and quizCandidate.started %}
<form action="{{ path('tvdt_backoffice_reset_candidate_progress', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline" onsubmit="return confirm('{{ 'Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.'|trans|e('js') }}');">
<input type="hidden" name="_token" value="{{ csrf_token('reset_candidate_progress') }}">
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'Reset progress'|trans }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -0,0 +1,11 @@
<turbo-frame id="add-candidates-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="modal-body">
{{ form_row(form.candidates) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Submit'|trans }}</button>
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -1,8 +1,10 @@
<div class="row">
<div class="row" data-controller="bo--modal" data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
<div class="col-md-6 col-12">
<div class="mb-3">
<a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}"
data-modal-title="{{ 'Add Candidate'|trans }}">{{ 'Add Candidate'|trans }}</button>
</div>
<ul class="list-group mb-3">
{% for candidate in season.candidates %}
@@ -17,7 +19,9 @@
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
</div>
<div class="modal fade" id="renameCandidate-{{ candidate.id }}" data-bs-backdrop="static"
<div class="modal fade" id="renameCandidate-{{ candidate.id }}"
data-controller="bo--modal" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
tabindex="-1" aria-labelledby="renameCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -31,7 +35,8 @@
<input type="hidden" name="_token" value="{{ csrf_token('rename_candidate') }}">
<label class="form-label" for="renameCandidateName-{{ candidate.id }}">{{ 'Name'|trans }}</label>
<input type="text" class="form-control" id="renameCandidateName-{{ candidate.id }}"
name="name" value="{{ candidate.name }}" maxlength="16" required autofocus>
name="name" value="{{ candidate.name }}" maxlength="16" required autofocus
data-action="input->bo--modal#markDirty change->bo--modal#markDirty">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
@@ -69,6 +74,23 @@
{{ 'No candidates'|trans }}
{% endfor %}
</ul>
<div class="modal fade" tabindex="-1"
data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="addCandidatesModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="addCandidatesModalLabel">{{ 'Add Candidate'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<turbo-frame id="add-candidates-modal-frame"
data-bo--modal-target="frame"
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
</div>
</div>
</div>
</div>
<div class="col-md-6 col-12">
{{ include('backoffice/help/season_candidates.html.twig') }}
@@ -173,6 +173,47 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
$this->assertTrue($updated->active);
}
public function testResetCandidateProgressDeletesGivenAnswersAndClearsStarted(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$candidate = $this->getCandidate('Tom');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable();
$this->entityManager->persist($quizCandidate);
$firstQuestion = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $firstQuestion);
$answer = $firstQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
$this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter(\sprintf('form[action*="/%s/reset"] input[name="_token"]', $candidate->id))->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/reset', $quiz->id, $candidate->id), [
'_token' => $token,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
'quiz' => $this->getQuizByName('Quiz 1'),
'candidate' => $this->getCandidate('Tom'),
]);
$this->assertInstanceOf(QuizCandidate::class, $updated);
$this->assertNotInstanceOf(\DateTimeImmutable::class, $updated->started);
$remainingAnswers = $this->entityManager->getRepository(GivenAnswer::class)->findBy([
'quiz' => $quiz,
'candidate' => $candidate,
]);
$this->assertCount(0, $remainingAnswers);
}
public function testModifyCorrection(): void
{
$quiz = $this->getQuizByName('Quiz 1');
@@ -104,6 +104,42 @@ final class SeasonControllerTest extends AbstractControllerWebTestCase
$this->assertNotInstanceOf(Candidate::class, $this->entityManager->getRepository(Candidate::class)->find($candidateId));
}
public function testAddCandidates(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/add-candidate');
$form = $this->client->getCrawler()->filter('form')->form([
'add_candidates_form[candidates]' => "Nora\nPiet",
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
$names = array_map(static fn (Candidate $candidate): string => $candidate->name, $season->candidates->toArray());
$this->assertContains('Nora', $names);
$this->assertContains('Piet', $names);
}
public function testAddCandidatesViaTurboFrameReturnsEmptyFrame(): void
{
$this->client->xmlHttpRequest(Request::METHOD_GET, '/backoffice/season/krtek/add-candidate', server: ['HTTP_TURBO-FRAME' => 'add-candidates-modal-frame']);
$form = $this->client->getCrawler()->filter('form')->form([
'add_candidates_form[candidates]' => 'Sanne',
]);
$this->client->submit($form, [], ['HTTP_TURBO-FRAME' => 'add-candidates-modal-frame']);
self::assertResponseIsSuccessful();
$this->assertStringContainsString('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>', (string) $this->client->getResponse()->getContent());
$this->entityManager->clear();
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
$names = array_map(static fn (Candidate $candidate): string => $candidate->name, $season->candidates->toArray());
$this->assertContains('Sanne', $names);
}
public function testRenameCandidateIsDeniedForNonOwner(): void
{
$candidate = $this->getCandidate('Tom');
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Repository\QuizRepository;
use Tvdt\Service\DataExportService;
use Tvdt\Service\QuizSpreadsheetService;
/**
* Reproduces a Sentry warning (PHP-SYMFONY-3Z): with more than 25 questions, the last raw-answers
* column goes past 'Z' (e.g. 'AA'), and range('A', 'AA') is invalid because range()'s second argument
* must be a single byte.
*/
#[CoversClass(DataExportService::class)]
final class DataExportServiceRawAnswersColumnsTest extends TestCase
{
public function testFillRawAnswersSheetHandlesMoreThanTwentyFiveQuestions(): void
{
$subject = new DataExportService(
$this->createStub(EntityManagerInterface::class),
$this->createStub(QuizSpreadsheetService::class),
$this->createStub(QuizRepository::class),
);
$quiz = new Quiz();
for ($i = 0; $i < 26; ++$i) {
$question = new Question();
$question->question = 'Question '.($i + 1);
$quiz->addQuestion($question);
}
$sheet = new Spreadsheet()->getActiveSheet();
$method = new \ReflectionMethod(DataExportService::class, 'fillRawAnswersSheet');
$method->invoke($subject, $sheet, $quiz);
$this->assertEqualsWithDelta(30.0, $sheet->getColumnDimension('AA')->getWidth(), \PHP_FLOAT_EPSILON);
}
}
+16
View File
@@ -117,6 +117,10 @@
<source>Are you sure you want to delete this quiz?</source>
<target>Weet je zeker dat je deze test wilt verwijderen?</target>
</trans-unit>
<trans-unit id="6XTrab." resname="Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.">
<source>Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.</source>
<target>Weet je zeker dat je de voortgang van deze kandidaat wilt resetten? De ingevulde antwoorden voor deze test worden verwijderd.</target>
</trans-unit>
<trans-unit id="4bcq6sL" resname="Assign">
<source>Assign</source>
<target>Toewijzen</target>
@@ -161,6 +165,10 @@
<source>Candidate not found</source>
<target>Kandidaat niet gevonden</target>
</trans-unit>
<trans-unit id="fl.hjdA" resname="Candidate progress reset">
<source>Candidate progress reset</source>
<target>Voortgang kandidaat gereset</target>
</trans-unit>
<trans-unit id="QH4e_Ho" resname="Candidate renamed">
<source>Candidate renamed</source>
<target>Kandidaat hernoemd</target>
@@ -541,6 +549,10 @@
<source>Number of dropouts:</source>
<target>Aantal afvallers:</target>
</trans-unit>
<trans-unit id="vxaREnf" resname="One candidate per line">
<source>One candidate per line</source>
<target>Eén kandidaat per regel</target>
</trans-unit>
<trans-unit id="_SqArFZ" resname="Open">
<source>Open</source>
<target>Openen</target>
@@ -761,6 +773,10 @@
<source>Reset password</source>
<target>Wachtwoord herstellen</target>
</trans-unit>
<trans-unit id="SSMxy68" resname="Reset progress">
<source>Reset progress</source>
<target>Voortgang resetten</target>
</trans-unit>
<trans-unit id="eyayNGN" resname="Reset your password">
<source>Reset your password</source>
<target>Wachtwoord herstellen</target>