From 90a16b08bf2ed56ae98b23fd4ef0005bceea8848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mike=C5=A1?= Date: Wed, 19 Aug 2026 20:51:44 +0200 Subject: [PATCH] Add-time picker: every approved competition and every edition of an approved series is selectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Competition / event" picker built its options from GetCompetitionEvents::allLive() + allPast(), both filtered series_id IS NULL AND approved_at IS NOT NULL. Editions of a recurring series are created with series_id set and approved_at NULL by design (the series approval governs visibility), so no edition was ever selectable; upcoming events were excluded too; a prefilled id missing from the options rendered empty and re-save detached the time; nothing validated the submitted id; option HTML interpolated organiser-authored names unescaped. - GetSelectableCompetitions: one query embedding IsCompetitionPubliclyVisible::SQL_CONDITION (+ the currently linked id on the edit form), ordered live → undated standalone (the perpetual online umbrellas) → past desc → upcoming asc → undated editions; undated editions are dated by their first round. - CompetitionChoicesBuilder + CompetitionChoices: TomSelect payload with an optgroup per series, escaped cards, lazy logos, searchable keywords (series name/shortcut); contains() backs the new server-side validation (forms.competition_not_selectable). Replaces three copy-pasted builders. - competition_picker Stimulus controller (maxOptions unlimited, optgroup header with logo, blur on select) — the old hook lived in time-form-autocomplete, which is not attached on the edit form. - Handlers keep the CompetitionNotFound → null fallback but log a warning. - Dead code removed: PuzzleSolvingTimeFormType/FormData, CompetitionAutocompleteController, AddPuzzle(SolvingTime)::fromFormData, GetCompetitionEvents::all/allLive/allPast/allUpcoming. Refs #204 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QUegPi2i9SjwkcrNWyENnL --- CLAUDE.md | 2 +- .../competition_picker_controller.js | 49 +++ .../time_form_autocomplete_controller.js | 22 +- assets/styles/app.scss | 22 ++ .../competitions-management/README.md | 11 + .../CompetitionAutocompleteController.php | 84 ----- src/Controller/EditTimeController.php | 6 +- src/FormData/PuzzleSolvingTimeFormData.php | 51 --- .../EditPuzzleSolvingTimeFormType.php | 92 ++---- src/FormType/PuzzleAddFormType.php | 88 ++--- src/FormType/PuzzleSolvingTimeFormType.php | 309 ------------------ src/Message/AddPuzzle.php | 22 -- src/Message/AddPuzzleSolvingTime.php | 24 -- .../AddPuzzleSolvingTimeHandler.php | 15 +- .../EditPuzzleSolvingTimeHandler.php | 14 +- src/Query/GetCompetitionEvents.php | 104 ------ src/Query/GetSelectableCompetitions.php | 107 ++++++ src/Results/SelectableCompetition.php | 80 +++++ src/Services/CompetitionChoicesBuilder.php | 166 ++++++++++ src/Value/CompetitionChoices.php | 31 ++ templates/_solving_time_form.html.twig | 5 +- tests/Controller/EditTimeControllerTest.php | 179 ++++++++++ tests/Controller/PuzzleAddControllerTest.php | 108 ++++++ .../AddPuzzleSolvingTimeHandlerTest.php | 4 +- tests/Query/GetCompetitionEventsTest.php | 4 +- tests/Query/GetSelectableCompetitionsTest.php | 238 ++++++++++++++ .../CompetitionChoicesBuilderTest.php | 170 ++++++++++ translations/messages.en.yml | 3 + 28 files changed, 1247 insertions(+), 763 deletions(-) create mode 100644 assets/controllers/competition_picker_controller.js delete mode 100644 src/Controller/CompetitionAutocompleteController.php delete mode 100644 src/FormData/PuzzleSolvingTimeFormData.php delete mode 100644 src/FormType/PuzzleSolvingTimeFormType.php create mode 100644 src/Query/GetSelectableCompetitions.php create mode 100644 src/Results/SelectableCompetition.php create mode 100644 src/Services/CompetitionChoicesBuilder.php create mode 100644 src/Value/CompetitionChoices.php create mode 100644 tests/Controller/EditTimeControllerTest.php create mode 100644 tests/Query/GetSelectableCompetitionsTest.php create mode 100644 tests/Services/CompetitionChoicesBuilderTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 74be8cbd9..66def8c22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ Feature design documents and implementation plans are in `docs/features/`. Each - **API & OAuth2**: `docs/features/api/` — Public REST API (V1), OAuth2 server, Swagger docs, internal APIs, deprecated V0 - **Stripe Payments**: `docs/features/stripe.md` — Stripe integration for premium membership - **Opt-Out Features**: `docs/features/opt-out.md` — Streak and ranking opt-out for players -- **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch +- **Competitions Management**: `docs/features/competitions-management/` — Community-driven event creation with admin approval, round management, puzzle assignment, table layout planning, and live stopwatch. Linking solving times to events (the add/edit-time "Competition / event" picker: selectable set = `IsCompetitionPubliclyVisible::SQL_CONDITION` incl. series editions, include-current rule on the edit form, server-side validation, `GetSelectableCompetitions` + `CompetitionChoicesBuilder`) — see README §"Linking solving times to events" - **Referral Program**: `docs/features/referral-program.md` — Members earn 10% of referred subscription revenue. No separate entity — `player.referralProgramJoinedAt` + `player.referralProgramSuspended`. Code = player code. Cookie-based + code-input attribution. Payouts per currency, manual admin payout marking - **Activity Analytics**: `docs/features/activity-analytics.md` — daily player presence (`player_activity_day`, UTC days, 24-month prune) + immortal per-locale aggregates (`activity_daily_summary`); written by `PlayerActivitySubscriber` on kernel.terminate with Redis dedup; crons `myspeedpuzzling:snapshot-activity-summary` + `myspeedpuzzling:prune-player-activity` (daily) - **Auth Hardening**: `docs/features/auth-hardening/README.md` — DB auth audit trail (`auth_audit_log` table, `RecordAuthAuditEvent` via `AuthAuditRecorder` — never breaks login), user-facing `/account/recent-activity` page, 24-month prune cron `myspeedpuzzling:prune-auth-audit-log`, GDPR deletion now removes `UserAccount`. Social login (PR 2): Google/Apple/Facebook via league provider libraries, `oauth_identity` table (D13), per-provider authenticators on `main`, cache-backed OAuth state (`social_login_state_cache` pool — Apple's form_post callback has no session), 5 settled linking rules in `SocialAccountResolver`, rule-4 interstitial `/register/social`, "Connected sign-in methods" on edit-profile, ≥1-sign-in-method invariant in `UnlinkOauthIdentityHandler`; flags `SOCIAL_LOGIN_{GOOGLE,FACEBOOK,APPLE}_ENABLED` (OFF) + `SOCIAL_LOGIN_ADMIN_ONLY` (ON) — see `docs/features/feature_flags.md` diff --git a/assets/controllers/competition_picker_controller.js b/assets/controllers/competition_picker_controller.js new file mode 100644 index 000000000..6bee8c731 --- /dev/null +++ b/assets/controllers/competition_picker_controller.js @@ -0,0 +1,49 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Tunes the TomSelect instance of the "Competition / event" picker (add-time + edit-time forms). + * + * Placed on the wrapper of the autocomplete input: the Symfony UX Autocomplete controller dispatches + * a bubbling `autocomplete:pre-connect` event with the TomSelect config right before instantiation, + * so the config can be patched here. These settings cannot come from PHP (`tom_select_options`) + * because ux-autocomplete merges its own `maxOptions`/`render` on top of them for ``-based + * pickers. + */ +export default class extends Controller { + initialize() { + this._onPreConnect = this._onPreConnect.bind(this); + } + + connect() { + this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect); + } + + disconnect() { + this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect); + } + + _onPreConnect(event) { + const options = event.detail.options; + + // ux-autocomplete forces 50 for -based pickers; the whole set must be browsable + options.maxOptions = null; + + options.render = options.render || {}; + options.render.optgroup_header = (data, escape) => + '
' + + (data.logo + ? '' + : '') + + escape(data.label) + + '
'; + + // Blur on select so the dropdown closes and the keyboard goes away on mobile + options.onChange = () => { + const tomSelect = event.target.tomselect; + + if (tomSelect) { + tomSelect.blur(); + } + }; + } +} diff --git a/assets/controllers/time_form_autocomplete_controller.js b/assets/controllers/time_form_autocomplete_controller.js index c34013bd1..416f6f0db 100644 --- a/assets/controllers/time_form_autocomplete_controller.js +++ b/assets/controllers/time_form_autocomplete_controller.js @@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'; import * as bootstrap from 'bootstrap'; export default class extends Controller { - static targets = ['brand', 'puzzle', 'competition', 'newPuzzle', 'scannerModal', 'scannerMessage', 'eanInput', 'hideOptions']; + static targets = ['brand', 'puzzle', 'newPuzzle', 'scannerModal', 'scannerMessage', 'eanInput', 'hideOptions']; static values = { eanSearchUrl: String, @@ -21,7 +21,6 @@ export default class extends Controller { _puzzleOptionsFetchPromise = null; // Track the current fetch promise for puzzle options initialize() { - this._onCompetitionConnect = this._onCompetitionConnect.bind(this); this._onBrandConnect = this._onBrandConnect.bind(this); this._onPuzzleConnect = this._onPuzzleConnect.bind(this); this._handleBarcodeScanned = this._handleBarcodeScanned.bind(this); @@ -37,11 +36,6 @@ export default class extends Controller { this.brandTarget.addEventListener('autocomplete:pre-connect', this._onBrandConnect); this.puzzleTarget.addEventListener('autocomplete:pre-connect', this._onPuzzleConnect); - if (this.hasCompetitionTarget) { - this.initialCompetitionValue = this.competitionTarget.value; - this.competitionTarget.addEventListener('autocomplete:pre-connect', this._onCompetitionConnect); - } - // Listen for barcode scanner events document.addEventListener('barcode-scanner:scanned', this._handleBarcodeScanned); @@ -55,10 +49,6 @@ export default class extends Controller { this.brandTarget.removeEventListener('autocomplete:pre-connect', this._onBrandConnect); this.puzzleTarget.removeEventListener('autocomplete:pre-connect', this._onPuzzleConnect); - if (this.hasCompetitionTarget) { - this.competitionTarget.removeEventListener('autocomplete:pre-connect', this._onCompetitionConnect); - } - document.removeEventListener('barcode-scanner:scanned', this._handleBarcodeScanned); document.removeEventListener('submit', this._onFormSubmit, true); } @@ -115,12 +105,6 @@ export default class extends Controller { }; } - _onCompetitionConnect(event) { - event.detail.options.onChange = (value) => { - this.onCompetitionValueChanged(value); - }; - } - onBrandValueChanged(value) { // Puzzle Tom Select may not be initialized yet (autocomplete is a lazy-loaded // controller) - handleInitialValues() picks up the current brand value on init @@ -186,10 +170,6 @@ export default class extends Controller { } } - onCompetitionValueChanged(value) { - this.competitionTarget.tomselect.blur(); - } - fetchPuzzleOptions(brandValue, openDropdown) { const fetchUrl = this.brandTarget.getAttribute('data-fetch-url'); diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 88b9e6a2d..d4e5eec18 100755 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -389,6 +389,28 @@ td.rank { line-height: 110%; } +// "Competition / event" picker on the add-time / edit-time forms (CompetitionChoicesBuilder cards, +// competition_picker_controller.js optgroup headers) +.competition-option { + line-height: 110%; +} + +.competition-option-logo { + max-width: 48px; + max-height: 48px; +} + +.competition-optgroup-logo { + width: 24px; + height: 24px; + object-fit: contain; +} + +[data-controller~="competition-picker"] .ts-dropdown-content { + // cards are ~48px tall; TomSelect's default 200px shows only 4 rows + max-height: 60vh; +} + .navbar-tool .navbar-tool-label { top: 1.6rem; right: -0.1rem; diff --git a/docs/features/competitions-management/README.md b/docs/features/competitions-management/README.md index 5711ced26..7881ea86b 100644 --- a/docs/features/competitions-management/README.md +++ b/docs/features/competitions-management/README.md @@ -126,6 +126,16 @@ A solving time may be linked to a standalone competition **or to a series editio **Public visibility of a competition row** (standalone or edition) is decided in one place, `IsCompetitionPubliclyVisible` (`check($competitionId)` + the reusable `SQL_CONDITION` fragment): a standalone competition is visible when approved and not rejected; an edition is visible iff its **series** is approved and not rejected — editions are never approved individually (their own `approved_at` stays `NULL`). The API competition detail uses this rule to decide what is readable. +### Linking solving times to events + +The "Competition / event" picker on the add-time form (`PuzzleAddFormType`, routes `puzzle_add` + `finish_stopwatch`) and the edit-time form (`EditPuzzleSolvingTimeFormType`, route `edit_time`) is one TomSelect field whose options are baked server-side (no remote endpoint, no caching): + +- **Selectable set** = exactly `IsCompetitionPubliclyVisible::SQL_CONDITION`: every approved & not-rejected standalone competition regardless of its date (live, past, upcoming, undated) **plus every edition whose series is approved & not rejected** (the edition's own `approved_at` is ignored, its own `rejected_at` is respected). The series umbrella itself is never selectable — a time links to a concrete edition. Read model: `GetSelectableCompetitions::all(?$alwaysIncludeCompetitionId)` → `SelectableCompetition` DTOs. +- **Include-current rule (edit form)**: `EditTimeController` passes the time's current `competition_id` (server-derived from the owner-checked row, never from the request) as the form option `current_competition_id`; the query adds that row unconditionally, so a link to a competition that is not (or no longer) publicly visible survives a re-save instead of rendering an empty control and silently detaching the time. +- **Validation**: `CompetitionChoicesBuilder::build()` returns a `CompetitionChoices` value (`options`, `optgroups`, `contains(id)`); the form types' `POST_SUBMIT` rule rejects any non-null submitted id the picker did not offer with the generic `forms.competition_not_selectable` error (never echoes names). The handlers' `CompetitionNotFound → null` fallback stays only for the render→submit race and logs a warning. +- **Ordering** (global, one SQL `ORDER BY`): live → undated standalone ("perpetual" online umbrellas, the most-used entries) → past (newest first) → upcoming (soonest first) → undated editions. Undated editions with rounds are dated by their first round (`MIN(competition_round.starts_at)`). Editions carry `optgroup` = series id and TomSelect renders a series' block where its best-ranked edition sits (`lockOptgroupOrder` off); standalone events are ungrouped. +- **Rendering**: option cards are built in `CompetitionChoicesBuilder` (every organiser-authored string HTML-escaped, lazy-loaded 48px logo falling back to the series logo, series name on edition cards, "live" badge, `keywords` = series name/shortcut + name/shortcut + location as extra `searchField`). `assets/controllers/competition_picker_controller.js` patches the TomSelect config on `autocomplete:pre-connect` (`maxOptions: null`, optgroup header with series logo, blur on select) — ux-autocomplete forces `maxOptions: 50` and its own `render` for ``-based pickers, so these cannot come from PHP. + ## Round Management A competition has multiple **rounds**, each with: @@ -294,3 +304,4 @@ All emails use the `transactional` mailer transport and follow the standard Inky 15. **Editions never auto-create rounds** — the edition form creates only the Competition, rounds are always managed separately via the round management UI 16. **Round category defaults to solo** — existing rounds get `solo` category via migration default 18. **Teams are scoped to rounds** — `CompetitionTeam` belongs to a `CompetitionRound`, participants are assigned to teams via `CompetitionParticipantRound.team_id` +19. **A solving time can be linked to any publicly visible competition row** — the add/edit-time picker offers every approved & not-rejected standalone competition (any date) and every edition of an approved & not-rejected series (`IsCompetitionPubliclyVisible::SQL_CONDITION`), never the series umbrella itself; the edit form additionally keeps the currently linked competition selectable; the submitted id is validated against exactly that set diff --git a/src/Controller/CompetitionAutocompleteController.php b/src/Controller/CompetitionAutocompleteController.php deleted file mode 100644 index 2b879bd3c..000000000 --- a/src/Controller/CompetitionAutocompleteController.php +++ /dev/null @@ -1,84 +0,0 @@ -getCompetitionEvents->all() as $competition) { - $img = ''; - - if ($competition->logo !== null) { - $img = << -HTML; - } - - $date = ''; - - if ($competition->dateFrom !== null) { - $date = $competition->dateFrom->format('d.m.Y'); - - if ($competition->dateTo !== null) { - $date .= ' - ' . $competition->dateTo->format('d.m.Y'); - } - } - - $location = ''; - - if ($competition->locationCountryCode !== null) { - $location = ''; - } - - $location .= $competition->location ?? ''; - - $html = << -
{$img}
-
-
- {$competition->name} - {$date} -
-
{$location}
-
- -HTML; - - $results[] = [ - 'value' => $competition->id, - 'text' => $html, - ]; - } - - return new JsonResponse([ - 'results' => $results, - ]); - } -} diff --git a/src/Controller/EditTimeController.php b/src/Controller/EditTimeController.php index 05f673c34..d57eb60c4 100644 --- a/src/Controller/EditTimeController.php +++ b/src/Controller/EditTimeController.php @@ -111,7 +111,11 @@ public function __invoke(Request $request, #[CurrentUser] UserInterface $user, s } } - $editTimeForm = $this->createForm(EditPuzzleSolvingTimeFormType::class, $data); + $editTimeForm = $this->createForm(EditPuzzleSolvingTimeFormType::class, $data, [ + // Server-derived from the owner-checked row, never from the request: the picker must + // keep offering the linked competition even when it is not publicly selectable + 'current_competition_id' => $solvedPuzzle->competitionId, + ]); $editTimeForm->handleRequest($request); if ($isGroupPuzzlersValid === true && $editTimeForm->isSubmitted() && $editTimeForm->isValid()) { diff --git a/src/FormData/PuzzleSolvingTimeFormData.php b/src/FormData/PuzzleSolvingTimeFormData.php deleted file mode 100644 index b5b2c5e21..000000000 --- a/src/FormData/PuzzleSolvingTimeFormData.php +++ /dev/null @@ -1,51 +0,0 @@ -finishedAt = new DateTimeImmutable(); - } -} diff --git a/src/FormType/EditPuzzleSolvingTimeFormType.php b/src/FormType/EditPuzzleSolvingTimeFormType.php index 22911d842..b308f5303 100644 --- a/src/FormType/EditPuzzleSolvingTimeFormType.php +++ b/src/FormType/EditPuzzleSolvingTimeFormType.php @@ -6,11 +6,11 @@ use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\FormData\EditPuzzleSolvingTimeFormData; -use SpeedPuzzling\Web\Query\GetCompetitionEvents; use SpeedPuzzling\Web\Services\BrandChoicesBuilder; +use SpeedPuzzling\Web\Services\CompetitionChoicesBuilder; use SpeedPuzzling\Web\Results\PuzzleOverview; -use SpeedPuzzling\Web\Twig\ImageThumbnailTwigExtension; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; +use SpeedPuzzling\Web\Value\CompetitionChoices; use SpeedPuzzling\Web\Value\PuzzleAddMode; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -40,8 +40,7 @@ public function __construct( readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, readonly private TranslatorInterface $translator, readonly private UrlGeneratorInterface $urlGenerator, - readonly private GetCompetitionEvents $getCompetitionEvents, - readonly private ImageThumbnailTwigExtension $imageThumbnail, + readonly private CompetitionChoicesBuilder $competitionChoicesBuilder, ) { } @@ -50,7 +49,6 @@ public function __construct( */ public function buildForm(FormBuilderInterface $builder, array $options): void { - $userProfile = $this->retrieveLoggedUserProfile->getProfile(); // Must not be null - solving time is allowed only to logged-in users assert($userProfile !== null); @@ -62,6 +60,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $brandChoices = $this->brandChoicesBuilder->build($userProfile->playerId, $extraManufacturerId); + // The competition this time is linked to is always offered, even when it is not publicly + // visible (any more) — otherwise the control renders empty and a re-save detaches the time + /** @var null|string $currentCompetitionId */ + $currentCompetitionId = $options['current_competition_id'] ?? null; + $competitionChoices = $this->competitionChoicesBuilder->build($currentCompetitionId); + // Mode field (hidden, controlled by JS) - only Speed and Relax modes for editing $builder->add('mode', EnumType::class, [ 'class' => PuzzleAddMode::class, @@ -103,7 +107,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'create' => false, 'persist' => false, 'maxItems' => 1, - 'options' => $this->getCompetitionsAutocompleteData(), + 'options' => $competitionChoices->options, + 'optgroups' => $competitionChoices->optgroups, + 'searchField' => ['text', 'keywords'], 'closeAfterSelect' => true, 'createOnBlur' => false, ], @@ -255,12 +261,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, ]); - $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($competitionChoices): void { $form = $event->getForm(); $data = $event->getData(); assert($data instanceof EditPuzzleSolvingTimeFormData); - $this->applyDynamicRules($form, $data); + $this->applyDynamicRules($form, $data, $competitionChoices); }); } @@ -269,7 +275,12 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setDefaults([ 'data_class' => EditPuzzleSolvingTimeFormData::class, 'active_puzzle' => null, + // The competition the edited time is currently linked to (server-derived by the controller, + // never from the request) — the picker always offers it, see CompetitionChoicesBuilder + 'current_competition_id' => null, ]); + + $resolver->setAllowedTypes('current_competition_id', ['null', 'string']); } /** @@ -278,6 +289,7 @@ public function configureOptions(OptionsResolver $resolver): void private function applyDynamicRules( FormInterface $form, EditPuzzleSolvingTimeFormData $data, + CompetitionChoices $competitionChoices, ): void { // Time is required only for Speed Puzzling mode if ($data->mode === PuzzleAddMode::SpeedPuzzling && $data->hasTime() === false) { @@ -294,68 +306,10 @@ private function applyDynamicRules( $form->get('puzzlePhoto')->addError(new FormError($this->translator->trans('forms.puzzle_photo_is_required'))); } } - } - - /** - * @return array - */ - public function getCompetitionsAutocompleteData(): array - { - $events = []; - $results = []; - - array_push($events, ...$this->getCompetitionEvents->allLive()); - array_push($events, ...$this->getCompetitionEvents->allPast()); - - foreach ($events as $competition) { - $img = ''; - - if ($competition->logo !== null) { - $img = << -HTML; - } - $date = ''; - - if ($competition->dateFrom !== null) { - $date = $competition->dateFrom->format('d.m.Y'); - - if ($competition->dateTo !== null) { - $date .= ' - ' . $competition->dateTo->format('d.m.Y'); - } - } - - $location = ''; - - if ($competition->locationCountryCode !== null) { - $location = ''; - } - - $location .= $competition->location ?? ''; - - $html = << -
{$img}
-
-
- {$competition->name} - {$date} -
-
{$location}
-
- -HTML; - - $results[] = [ - 'value' => $competition->id, - 'text' => $html, - ]; + // Competition: only an id the picker offered — selectable OR the currently linked one + if ($data->competition !== null && $competitionChoices->contains($data->competition) === false) { + $form->get('competition')->addError(new FormError($this->translator->trans('forms.competition_not_selectable'))); } - - return $results; } } diff --git a/src/FormType/PuzzleAddFormType.php b/src/FormType/PuzzleAddFormType.php index 9f09192f4..afb4f4681 100644 --- a/src/FormType/PuzzleAddFormType.php +++ b/src/FormType/PuzzleAddFormType.php @@ -6,12 +6,12 @@ use Ramsey\Uuid\Uuid; use SpeedPuzzling\Web\FormData\PuzzleAddFormData; -use SpeedPuzzling\Web\Query\GetCompetitionEvents; use SpeedPuzzling\Web\Services\BrandChoicesBuilder; +use SpeedPuzzling\Web\Services\CompetitionChoicesBuilder; use SpeedPuzzling\Web\Results\PuzzleOverview; -use SpeedPuzzling\Web\Twig\ImageThumbnailTwigExtension; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; use SpeedPuzzling\Web\Value\CollectionVisibility; +use SpeedPuzzling\Web\Value\CompetitionChoices; use SpeedPuzzling\Web\Value\PuzzleAddMode; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -41,8 +41,7 @@ public function __construct( readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, readonly private TranslatorInterface $translator, readonly private UrlGeneratorInterface $urlGenerator, - readonly private GetCompetitionEvents $getCompetitionEvents, - readonly private ImageThumbnailTwigExtension $imageThumbnail, + readonly private CompetitionChoicesBuilder $competitionChoicesBuilder, ) { } @@ -62,6 +61,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $brandChoices = $this->brandChoicesBuilder->build($userProfile->playerId, $extraManufacturerId); + /** @var null|string $currentCompetitionId */ + $currentCompetitionId = $options['current_competition_id'] ?? null; + $competitionChoices = $this->competitionChoicesBuilder->build($currentCompetitionId); + // Mode field (hidden, controlled by JS) $builder->add('mode', EnumType::class, [ 'class' => PuzzleAddMode::class, @@ -203,7 +206,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'create' => false, 'persist' => false, 'maxItems' => 1, - 'options' => $this->getCompetitionsAutocompleteData(), + 'options' => $competitionChoices->options, + 'optgroups' => $competitionChoices->optgroups, + 'searchField' => ['text', 'keywords'], 'closeAfterSelect' => true, 'createOnBlur' => false, ], @@ -326,12 +331,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], ]); - $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($competitionChoices): void { $form = $event->getForm(); $data = $event->getData(); assert($data instanceof PuzzleAddFormData); - $this->applyDynamicRules($form, $data); + $this->applyDynamicRules($form, $data, $competitionChoices); }); } @@ -342,10 +347,14 @@ public function configureOptions(OptionsResolver $resolver): void 'active_puzzle' => null, 'collections' => [], 'has_active_membership' => true, + // The competition the edited time is linked to — always offered by the picker (edit form); + // symmetric with EditPuzzleSolvingTimeFormType, unused by the add form so far + 'current_competition_id' => null, ]); $resolver->setAllowedTypes('collections', 'array'); $resolver->setAllowedTypes('has_active_membership', 'bool'); + $resolver->setAllowedTypes('current_competition_id', ['null', 'string']); } /** @@ -354,6 +363,7 @@ public function configureOptions(OptionsResolver $resolver): void private function applyDynamicRules( FormInterface $form, PuzzleAddFormData $data, + CompetitionChoices $competitionChoices, ): void { $mode = $data->mode; @@ -394,68 +404,10 @@ private function applyDynamicRules( if ($mode === PuzzleAddMode::Collection && empty($data->collection)) { $form->get('collection')->addError(new FormError($this->translator->trans('forms.required_field'))); } - } - - /** - * @return array - */ - public function getCompetitionsAutocompleteData(): array - { - $events = []; - $results = []; - - array_push($events, ...$this->getCompetitionEvents->allLive()); - array_push($events, ...$this->getCompetitionEvents->allPast()); - - foreach ($events as $competition) { - $img = ''; - - if ($competition->logo !== null) { - $img = << -HTML; - } - - $date = ''; - - if ($competition->dateFrom !== null) { - $date = $competition->dateFrom->format('d.m.Y'); - if ($competition->dateTo !== null) { - $date .= ' - ' . $competition->dateTo->format('d.m.Y'); - } - } - - $location = ''; - - if ($competition->locationCountryCode !== null) { - $location = ''; - } - - $location .= $competition->location ?? ''; - - $html = << -
{$img}
-
-
- {$competition->name} - {$date} -
-
{$location}
-
- -HTML; - - $results[] = [ - 'value' => $competition->id, - 'text' => $html, - ]; + // Competition: only an id the picker offered (TomSelect can't create, but the value is user-controlled) + if ($data->competition !== null && $competitionChoices->contains($data->competition) === false) { + $form->get('competition')->addError(new FormError($this->translator->trans('forms.competition_not_selectable'))); } - - return $results; } } diff --git a/src/FormType/PuzzleSolvingTimeFormType.php b/src/FormType/PuzzleSolvingTimeFormType.php deleted file mode 100644 index 0827b446c..000000000 --- a/src/FormType/PuzzleSolvingTimeFormType.php +++ /dev/null @@ -1,309 +0,0 @@ - - */ -final class PuzzleSolvingTimeFormType extends AbstractType -{ - public function __construct( - readonly private BrandChoicesBuilder $brandChoicesBuilder, - readonly private RetrieveLoggedUserProfile $retrieveLoggedUserProfile, - readonly private TranslatorInterface $translator, - readonly private UrlGeneratorInterface $urlGenerator, - readonly private GetCompetitionEvents $getCompetitionEvents, - readonly private ImageThumbnailTwigExtension $imageThumbnail, - ) { - } - - /** - * @param mixed[] $options - */ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - - $userProfile = $this->retrieveLoggedUserProfile->getProfile(); - // Must not be null - solving time is allowed only to logged-in users - assert($userProfile !== null); - - /** @var null|PuzzleOverview $activePuzzle */ - $activePuzzle = $options['active_puzzle']; - - $extraManufacturerId = $activePuzzle?->manufacturerId; - - $brandChoices = $this->brandChoicesBuilder->build($userProfile->playerId, $extraManufacturerId); - - $builder->add('brand', TextType::class, [ - 'label' => 'forms.brand', - 'help' => 'forms.brand_help', - 'required' => true, - 'autocomplete' => true, - 'options_as_html' => true, - 'empty_data' => '', - 'tom_select_options' => [ - 'create' => true, - 'persist' => false, - 'maxItems' => 1, - 'options' => $brandChoices, - 'closeAfterSelect' => true, - 'createOnBlur' => true, - 'searchField' => ['text', 'eanPrefix'], - ], - 'attr' => [ - 'data-fetch-url' => $this->urlGenerator->generate('puzzle_by_brand_autocomplete'), - ], - ]); - - $builder->add('competition', TextType::class, [ - 'label' => 'forms.competition', - 'help' => 'forms.competition_help', - 'required' => false, - 'autocomplete' => true, - 'options_as_html' => true, - 'tom_select_options' => [ - 'create' => false, - 'persist' => false, - 'maxItems' => 1, - 'options' => $this->getCompetitionsAutocompleteData(), - 'closeAfterSelect' => true, - 'createOnBlur' => false, - ], - ]); - - $builder->add('firstAttempt', CheckboxType::class, [ - 'label' => 'forms.first_attempt', - 'required' => false, - 'help' => 'forms.first_attempt_help', - ]); - - $builder->add('unboxed', CheckboxType::class, [ - 'label' => 'forms.unboxed', - 'required' => false, - 'help' => 'forms.unboxed_help', - ]); - - $builder->add('puzzle', TextType::class, [ - 'label' => 'forms.puzzle', - 'help' => 'forms.puzzle_help', - 'required' => true, - 'autocomplete' => true, - 'options_as_html' => true, - 'tom_select_options' => [ - 'create' => true, - 'persist' => false, - 'maxItems' => 1, - 'closeAfterSelect' => true, - 'createOnBlur' => true, - ], - 'attr' => [ - 'data-choose-brand-placeholder' => $this->translator->trans('forms.puzzle_choose_brand_placeholder'), - 'data-choose-puzzle-placeholder' => $this->translator->trans('forms.puzzle_choose_placeholder'), - ], - ]); - - $builder->add('time', TextType::class, [ - 'label' => 'time', - 'required' => true, - 'attr' => [ - 'placeholder' => 'forms.time_format_placeholder', - ], - ]); - - $builder->add('comment', TextareaType::class, [ - 'label' => 'forms.comment', - 'required' => false, - ]); - - $builder->add('finishedPuzzlesPhoto', FileType::class, [ - 'label' => 'forms.finished_puzzle_photo', - 'required' => false, - 'constraints' => [ - new Image( - maxSize: '10m', - mimeTypes: [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/heic', - 'image/heif', - 'image/avif', - ], - mimeTypesMessage: 'image_invalid_mime_type' - ), - ], - ]); - - $builder->add('puzzlePiecesCount', NumberType::class, [ - 'label' => 'forms.pieces_count', - 'label_attr' => ['class' => 'required'], - 'required' => false, - ]); - - $builder->add('puzzlePhoto', FileType::class, [ - 'label' => 'forms.puzzle_box_photo', - 'required' => false, - 'label_attr' => ['class' => 'required'], - 'constraints' => [ - new Image( - maxSize: '10m', - mimeTypes: [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/heic', - 'image/heif', - 'image/avif', - ], - mimeTypesMessage: 'image_invalid_mime_type' - ), - ], - ]); - - $builder->add('finishedAt', DateType::class, [ - 'label' => 'forms.date_finished', - 'required' => false, - 'widget' => 'single_text', - 'format' => 'dd.MM.yyyy', - 'html5' => false, - 'input' => 'datetime_immutable', - 'input_format' => 'd.m.Y', - ]); - - $builder->add('puzzleEan', TextType::class, [ - 'label' => 'forms.ean', - 'required' => false, - ]); - - $builder->add('puzzleIdentificationNumber', TextType::class, [ - 'label' => 'forms.puzzle_identification_number', - 'required' => false, - ]); - - $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void { - $form = $event->getForm(); - $data = $event->getData(); - assert($data instanceof PuzzleSolvingTimeFormData); - - $this->applyDynamicRules($form, $data); - }); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'data_class' => PuzzleSolvingTimeFormData::class, - 'active_puzzle' => null, - ]); - } - - /** - * @param FormInterface $form - */ - private function applyDynamicRules( - FormInterface $form, - PuzzleSolvingTimeFormData $data, - ): void { - // TODO: Should check if the puzzle exists in database as well - if (is_string($data->puzzle) && Uuid::isValid($data->puzzle) === false) { - if ($data->puzzlePiecesCount === null) { - $form->get('puzzlePiecesCount')->addError(new FormError($this->translator->trans('forms.required_field'))); - } - - if ($data->puzzlePhoto === null && $data->finishedPuzzlesPhoto === null) { - $form->get('puzzlePhoto')->addError(new FormError($this->translator->trans('forms.puzzle_photo_is_required'))); - } - } - } - - /** - * @return array - */ - public function getCompetitionsAutocompleteData(): array - { - $events = []; - $results = []; - - array_push($events, ...$this->getCompetitionEvents->allLive()); - array_push($events, ...$this->getCompetitionEvents->allPast()); - - foreach ($events as $competition) { - $img = ''; - - if ($competition->logo !== null) { - $img = << -HTML; - } - - $date = ''; - - if ($competition->dateFrom !== null) { - $date = $competition->dateFrom->format('d.m.Y'); - - if ($competition->dateTo !== null) { - $date .= ' - ' . $competition->dateTo->format('d.m.Y'); - } - } - - $location = ''; - - if ($competition->locationCountryCode !== null) { - $location = ''; - } - - $location .= $competition->location ?? ''; - - $html = << -
{$img}
-
-
- {$competition->name} - {$date} -
-
{$location}
-
- -HTML; - - $results[] = [ - 'value' => $competition->id, - 'text' => $html, - ]; - } - - return $results; - } -} diff --git a/src/Message/AddPuzzle.php b/src/Message/AddPuzzle.php index be1bf0787..dab22927e 100644 --- a/src/Message/AddPuzzle.php +++ b/src/Message/AddPuzzle.php @@ -5,7 +5,6 @@ namespace SpeedPuzzling\Web\Message; use Ramsey\Uuid\UuidInterface; -use SpeedPuzzling\Web\FormData\PuzzleSolvingTimeFormData; use Symfony\Component\HttpFoundation\File\UploadedFile; readonly final class AddPuzzle @@ -21,25 +20,4 @@ public function __construct( public null|string $puzzleIdentificationNumber, ) { } - - public static function fromFormData( - UuidInterface $newPuzzleId, - string $userId, - PuzzleSolvingTimeFormData $data, - ): self { - assert($data->puzzle !== null); - assert($data->puzzlePiecesCount !== null); - assert($data->brand !== null); - - return new self( - puzzleId: $newPuzzleId, - userId: $userId, - puzzleName: $data->puzzle, - brand: $data->brand, - piecesCount: $data->puzzlePiecesCount, - puzzlePhoto: $data->puzzlePhoto, - puzzleEan: $data->puzzleEan, - puzzleIdentificationNumber: $data->puzzleIdentificationNumber, - ); - } } diff --git a/src/Message/AddPuzzleSolvingTime.php b/src/Message/AddPuzzleSolvingTime.php index 1c418ad00..a8ba1e78a 100644 --- a/src/Message/AddPuzzleSolvingTime.php +++ b/src/Message/AddPuzzleSolvingTime.php @@ -6,7 +6,6 @@ use DateTimeImmutable; use Ramsey\Uuid\UuidInterface; -use SpeedPuzzling\Web\FormData\PuzzleSolvingTimeFormData; use Symfony\Component\HttpFoundation\File\UploadedFile; readonly final class AddPuzzleSolvingTime @@ -27,27 +26,4 @@ public function __construct( public null|string $roundId = null, ) { } - - /** - * @param array $groupPlayers - */ - public static function fromFormData(UuidInterface $timeId, string $userId, array $groupPlayers, PuzzleSolvingTimeFormData $data): self - { - assert($data->puzzle !== null); - assert($data->time !== null); - - return new self( - timeId: $timeId, - userId: $userId, - puzzleId: $data->puzzle, - competitionId: $data->competition, - time: $data->time, - comment: $data->comment, - finishedPuzzlesPhoto: $data->finishedPuzzlesPhoto, - groupPlayers: $groupPlayers, - finishedAt: $data->finishedAt, - firstAttempt: $data->firstAttempt, - unboxed: $data->unboxed, - ); - } } diff --git a/src/MessageHandler/AddPuzzleSolvingTimeHandler.php b/src/MessageHandler/AddPuzzleSolvingTimeHandler.php index 7c1a6c53e..1090786bd 100644 --- a/src/MessageHandler/AddPuzzleSolvingTimeHandler.php +++ b/src/MessageHandler/AddPuzzleSolvingTimeHandler.php @@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use League\Flysystem\Filesystem; use Psr\Clock\ClockInterface; +use Psr\Log\LoggerInterface; use SpeedPuzzling\Web\Entity\PuzzleSolvingTime; use SpeedPuzzling\Web\Exceptions\CanNotAssembleEmptyGroup; use SpeedPuzzling\Web\Exceptions\CompetitionNotFound; @@ -37,6 +38,7 @@ public function __construct( private CompetitionRoundRepository $competitionRoundRepository, private ImageOptimizer $imageOptimizer, private MistypedYearNormalizer $mistypedYearNormalizer, + private LoggerInterface $logger, ) { } @@ -66,8 +68,17 @@ public function __invoke(AddPuzzleSolvingTime $message): void } elseif ($message->competitionId !== null) { try { $competition = $this->competitionRepository->get($message->competitionId); - } catch (CompetitionNotFound) { - // Already null ... + } catch (CompetitionNotFound $e) { + // The form validates the id against the picker's set, so this is only reachable when the + // competition disappeared between render and submit — saving the time without the link + // beats losing the upload, but it must not pass silently + $this->logger->warning('Solving time saved without competition: submitted competition id does not exist', [ + 'timeId' => $solvingTimeId->toString(), + 'puzzleId' => $message->puzzleId, + 'competitionId' => $message->competitionId, + 'userId' => $message->userId, + 'exception' => $e, + ]); } } diff --git a/src/MessageHandler/EditPuzzleSolvingTimeHandler.php b/src/MessageHandler/EditPuzzleSolvingTimeHandler.php index 663c75841..75bf4815e 100644 --- a/src/MessageHandler/EditPuzzleSolvingTimeHandler.php +++ b/src/MessageHandler/EditPuzzleSolvingTimeHandler.php @@ -6,6 +6,7 @@ use League\Flysystem\Filesystem; use Psr\Clock\ClockInterface; +use Psr\Log\LoggerInterface; use SpeedPuzzling\Web\Exceptions\CanNotAssembleEmptyGroup; use SpeedPuzzling\Web\Exceptions\CanNotModifyOtherPlayersTime; use SpeedPuzzling\Web\Exceptions\CompetitionNotFound; @@ -34,6 +35,7 @@ public function __construct( private CompetitionRepository $competitionRepository, private ImageOptimizer $imageOptimizer, private MistypedYearNormalizer $mistypedYearNormalizer, + private LoggerInterface $logger, ) { } @@ -58,8 +60,16 @@ public function __invoke(EditPuzzleSolvingTime $message): void if ($message->competitionId !== null) { try { $competition = $this->competitionRepository->get($message->competitionId); - } catch (CompetitionNotFound) { - // Already null ... + } catch (CompetitionNotFound $e) { + // The form validates the id against the picker's set, so this is only reachable when the + // competition disappeared between render and submit — saving the time without the link + // beats failing the edit, but it must not pass silently + $this->logger->warning('Solving time saved without competition: submitted competition id does not exist', [ + 'timeId' => $message->puzzleSolvingTimeId, + 'competitionId' => $message->competitionId, + 'userId' => $message->currentUserId, + 'exception' => $e, + ]); } } diff --git a/src/Query/GetCompetitionEvents.php b/src/Query/GetCompetitionEvents.php index 78a464994..88721f076 100644 --- a/src/Query/GetCompetitionEvents.php +++ b/src/Query/GetCompetitionEvents.php @@ -115,110 +115,6 @@ public function search( }, $data); } - /** - * @return array - */ - public function allPast(): array - { - $query = <<clock->now(); - - $data = $this->database - ->executeQuery($query, [ - 'date' => $date->format('Y-m-d H:i:s'), - ]) - ->fetchAllAssociative(); - - return array_map(static function (array $row): CompetitionEvent { - /** @var CompetitionEventDatabaseRow $row */ - return CompetitionEvent::fromDatabaseRow($row); - }, $data); - } - - /** - * @return array - */ - public function allUpcoming(): array - { - $query = << :date::date -ORDER BY date_from; -SQL; - $now = $this->clock->now(); - - $data = $this->database - ->executeQuery($query, [ - 'date' => $now->format('Y-m-d H:i:s'), - ]) - ->fetchAllAssociative(); - - return array_map(static function (array $row): CompetitionEvent { - /** @var CompetitionEventDatabaseRow $row */ - return CompetitionEvent::fromDatabaseRow($row); - }, $data); - } - - /** - * @return array - */ - public function allLive(): array - { - $query = <<clock->now(); - - $data = $this->database - ->executeQuery($query, [ - 'date' => $now->format('Y-m-d H:i:s'), - ]) - ->fetchAllAssociative(); - - return array_map(static function (array $row): CompetitionEvent { - /** @var CompetitionEventDatabaseRow $row */ - return CompetitionEvent::fromDatabaseRow($row); - }, $data); - } - - /** - * @return array - */ - public function all(): array - { - $query = <<database - ->executeQuery($query) - ->fetchAllAssociative(); - - return array_map(static function (array $row): CompetitionEvent { - /** @var CompetitionEventDatabaseRow $row */ - return CompetitionEvent::fromDatabaseRow($row); - }, $data); - } - /** * @return array */ diff --git a/src/Query/GetSelectableCompetitions.php b/src/Query/GetSelectableCompetitions.php new file mode 100644 index 000000000..cf3df7f95 --- /dev/null +++ b/src/Query/GetSelectableCompetitions.php @@ -0,0 +1,107 @@ + + */ + public function all(null|string $alwaysIncludeCompetitionId = null): array + { + if ($alwaysIncludeCompetitionId !== null && Uuid::isValid($alwaysIncludeCompetitionId) === false) { + $alwaysIncludeCompetitionId = null; + } + + $visibilityCondition = IsCompetitionPubliclyVisible::SQL_CONDITION; + + $query = << :today::date THEN 'upcoming' + ELSE 'past' + END AS event_status + FROM competition c + LEFT JOIN competition_series cs ON cs.id = c.series_id + LEFT JOIN LATERAL ( + SELECT MIN(starts_at) AS first_round_at + FROM competition_round + WHERE competition_id = c.id + ) r ON true + WHERE ({$visibilityCondition}) + OR c.id = :alwaysIncludeId +) s +ORDER BY + CASE + WHEN s.event_status = 'live' THEN 1 + WHEN s.event_status = 'undated' AND s.series_id IS NULL THEN 2 + WHEN s.event_status = 'past' THEN 3 + WHEN s.event_status = 'upcoming' THEN 4 + ELSE 5 + END, + CASE WHEN s.event_status = 'past' THEN s.effective_from END DESC NULLS LAST, + CASE WHEN s.event_status IN ('live', 'upcoming') THEN s.effective_from END ASC NULLS LAST, + s.name ASC, + s.id ASC +SQL; + + $data = $this->database + ->executeQuery($query, [ + 'today' => $this->clock->now()->format('Y-m-d'), + 'alwaysIncludeId' => $alwaysIncludeCompetitionId, + ]) + ->fetchAllAssociative(); + + return array_map(static function (array $row): SelectableCompetition { + /** @var SelectableCompetitionDatabaseRow $row */ + return SelectableCompetition::fromDatabaseRow($row); + }, $data); + } +} diff --git a/src/Results/SelectableCompetition.php b/src/Results/SelectableCompetition.php new file mode 100644 index 000000000..637a7eb71 --- /dev/null +++ b/src/Results/SelectableCompetition.php @@ -0,0 +1,80 @@ +getSelectableCompetitions->all($alwaysIncludeCompetitionId) as $competition) { + if ($competition->seriesId !== null && isset($seenSeries[$competition->seriesId]) === false) { + $seenSeries[$competition->seriesId] = true; + + $optgroup = [ + 'value' => $competition->seriesId, + 'label' => $competition->seriesName ?? '', + ]; + + if ($competition->seriesLogo !== null) { + $optgroup['logo'] = $this->imageThumbnail->thumbnailUrl($competition->seriesLogo, 'puzzle_small'); + } + + $optgroups[] = $optgroup; + } + + $option = [ + 'value' => $competition->id, + 'text' => $this->renderCard($competition), + 'keywords' => $this->keywords($competition), + ]; + + if ($competition->seriesId !== null) { + $option['optgroup'] = $competition->seriesId; + } + + $options[] = $option; + $ids[$competition->id] = true; + } + + return new CompetitionChoices($options, $optgroups, $ids); + } + + private function renderCard(SelectableCompetition $competition): string + { + $img = ''; + + if ($competition->logo !== null) { + $logoUrl = self::escape($this->imageThumbnail->thumbnailUrl($competition->logo, 'puzzle_small')); + + $img = << +HTML; + } + + $date = ''; + + if ($competition->dateFrom !== null) { + $date = $competition->dateFrom->format('d.m.Y'); + + if ($competition->dateTo !== null) { + $date .= ' - ' . $competition->dateTo->format('d.m.Y'); + } + } + + $liveBadge = ''; + + if ($competition->eventStatus === 'live') { + $liveBadge = '' . self::escape($this->translator->trans('forms.competition_live_badge')) . ''; + } + + $location = ''; + + if ($competition->locationCountryCode !== null) { + $location = ''; + } + + if ($competition->location !== null) { + $location .= self::escape($competition->location); + } + + $descriptionParts = []; + + // An edition card carries its series name, so the selected item in the control stays + // self-descriptive ("EJJ #68" alone says nothing once the optgroup header is out of sight). + if ($competition->seriesId !== null && $competition->seriesName !== null) { + $descriptionParts[] = self::escape($competition->seriesName); + } + + if ($location !== '') { + $descriptionParts[] = $location; + } + + $description = implode(' · ', $descriptionParts); + $name = self::escape($competition->name); + + return << +
{$img}
+
+
+ {$name} + {$date}{$liveBadge} +
+
{$description}
+
+ +HTML; + } + + /** + * Plain-text search terms TomSelect matches besides the (tag-stripped) card text. + */ + private function keywords(SelectableCompetition $competition): string + { + $parts = array_filter( + [ + $competition->seriesName, + $competition->seriesShortcut, + $competition->name, + $competition->shortcut, + $competition->location, + ], + static fn (null|string $part): bool => $part !== null && trim($part) !== '', + ); + + return trim(implode(' ', $parts)); + } + + private static function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES); + } +} diff --git a/src/Value/CompetitionChoices.php b/src/Value/CompetitionChoices.php new file mode 100644 index 000000000..f2e5ceca5 --- /dev/null +++ b/src/Value/CompetitionChoices.php @@ -0,0 +1,31 @@ + $options + * Options in display order; editions carry `optgroup` = series id, standalone events are ungrouped. + * @param list $optgroups + * One per series that has at least one selectable edition; `value` = series id. + * @param array $ids Every option value, for O(1) membership checks. + */ + public function __construct( + public array $options, + public array $optgroups, + private array $ids, + ) { + } + + public function contains(string $competitionId): bool + { + return isset($this->ids[$competitionId]); + } +} diff --git a/templates/_solving_time_form.html.twig b/templates/_solving_time_form.html.twig index 45f1e014b..2bf6a9482 100644 --- a/templates/_solving_time_form.html.twig +++ b/templates/_solving_time_form.html.twig @@ -250,10 +250,11 @@ {{ 'puzzle_add.competition_result'|trans }} -
+
{{ form_label(solving_time_form.competition) }} - {{ form_widget(solving_time_form.competition, {'attr': {'data-time-form-autocomplete-target': 'competition'}}) }} + {{ form_widget(solving_time_form.competition) }} {{ form_help(solving_time_form.competition) }} +
{{ 'forms.competition_hint_series'|trans }}
{{ form_errors(solving_time_form.competition) }}
diff --git a/tests/Controller/EditTimeControllerTest.php b/tests/Controller/EditTimeControllerTest.php new file mode 100644 index 000000000..5beff8db9 --- /dev/null +++ b/tests/Controller/EditTimeControllerTest.php @@ -0,0 +1,179 @@ +request('GET', self::EDIT_URL); + + $this->assertResponseRedirects(); + } + + public function testOtherPlayersTimeIsForbidden(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_PRIVATE); + + $browser->request('GET', self::EDIT_URL); + + $this->assertResponseStatusCodeSame(403); + } + + public function testLinkedSeriesEditionIsOfferedAndSurvivesResave(): void + { + $browser = self::createClient(); + $database = self::getContainer()->get(Connection::class); + $this->linkTimeTo($database, CompetitionSeriesFixture::EDITION_EJJ_68); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', self::EDIT_URL); + $this->assertResponseIsSuccessful(); + + $competitionInput = $crawler->filter('#edit_puzzle_solving_time_form_competition'); + self::assertSame(CompetitionSeriesFixture::EDITION_EJJ_68, $competitionInput->attr('value')); + $tomSelectOptions = $competitionInput->attr('data-symfony--ux-autocomplete--autocomplete-tom-select-options-value'); + self::assertNotNull($tomSelectOptions); + self::assertStringContainsString(CompetitionSeriesFixture::EDITION_EJJ_68, $tomSelectOptions); + + $browser->request('POST', self::EDIT_URL, [ + 'edit_puzzle_solving_time_form' => $this->submission($crawler, CompetitionSeriesFixture::EDITION_EJJ_68), + ]); + + $this->assertResponseRedirects(); + self::assertSame(CompetitionSeriesFixture::EDITION_EJJ_68, $this->linkedCompetitionId($database)); + } + + public function testCurrentlyLinkedNotPubliclySelectableCompetitionIsKeptOnResave(): void + { + // The link predates an approval decision (or the event got rejected later) - the picker must + // still offer it, otherwise the control renders empty and a plain re-save detaches the time + $browser = self::createClient(); + $database = self::getContainer()->get(Connection::class); + $this->linkTimeTo($database, CompetitionFixture::COMPETITION_UNAPPROVED); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', self::EDIT_URL); + $this->assertResponseIsSuccessful(); + + $tomSelectOptions = $crawler + ->filter('#edit_puzzle_solving_time_form_competition') + ->attr('data-symfony--ux-autocomplete--autocomplete-tom-select-options-value'); + self::assertNotNull($tomSelectOptions); + self::assertStringContainsString(CompetitionFixture::COMPETITION_UNAPPROVED, $tomSelectOptions); + self::assertSame(1, substr_count($tomSelectOptions, CompetitionFixture::COMPETITION_UNAPPROVED)); + + $browser->request('POST', self::EDIT_URL, [ + 'edit_puzzle_solving_time_form' => $this->submission($crawler, CompetitionFixture::COMPETITION_UNAPPROVED), + ]); + + $this->assertResponseRedirects(); + self::assertSame(CompetitionFixture::COMPETITION_UNAPPROVED, $this->linkedCompetitionId($database)); + } + + public function testSwitchingToANotSelectableCompetitionIsRejected(): void + { + $browser = self::createClient(); + $database = self::getContainer()->get(Connection::class); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', self::EDIT_URL); + $this->assertResponseIsSuccessful(); + + $tomSelectOptions = $crawler + ->filter('#edit_puzzle_solving_time_form_competition') + ->attr('data-symfony--ux-autocomplete--autocomplete-tom-select-options-value'); + self::assertNotNull($tomSelectOptions); + self::assertStringNotContainsString(CompetitionFixture::COMPETITION_UNAPPROVED, $tomSelectOptions); + + $browser->request('POST', self::EDIT_URL, [ + 'edit_puzzle_solving_time_form' => $this->submission($crawler, CompetitionFixture::COMPETITION_UNAPPROVED), + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertSelectorTextContains('form[name="edit_puzzle_solving_time_form"]', "This competition or event can't be selected"); + self::assertNull($this->linkedCompetitionId($database)); + } + + public function testSwitchingToASeriesEditionLinksTheTime(): void + { + $browser = self::createClient(); + $database = self::getContainer()->get(Connection::class); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', self::EDIT_URL); + $this->assertResponseIsSuccessful(); + + $browser->request('POST', self::EDIT_URL, [ + 'edit_puzzle_solving_time_form' => $this->submission($crawler, CompetitionSeriesFixture::EDITION_OFFLINE_1), + ]); + + $this->assertResponseRedirects(); + self::assertSame(CompetitionSeriesFixture::EDITION_OFFLINE_1, $this->linkedCompetitionId($database)); + } + + /** + * @return array + */ + private function submission(Crawler $crawler, string $competitionId): array + { + $csrfToken = $crawler->filter('input[name="edit_puzzle_solving_time_form[_token]"]')->attr('value'); + self::assertNotNull($csrfToken); + + return [ + '_token' => $csrfToken, + 'mode' => 'speed_puzzling', + 'brand' => ManufacturerFixture::MANUFACTURER_RAVENSBURGER, + 'puzzle' => PuzzleFixture::PUZZLE_500_02, + 'timeHours' => '0', + 'timeMinutes' => '36', + 'timeSeconds' => '40', + 'finishedAt' => '12.07.2026', + 'competition' => $competitionId, + ]; + } + + private function linkTimeTo(Connection $database, string $competitionId): void + { + $database->executeStatement( + 'UPDATE puzzle_solving_time SET competition_id = :competitionId WHERE id = :id', + ['competitionId' => $competitionId, 'id' => self::TIME_ID], + ); + } + + private function linkedCompetitionId(Connection $database): null|string + { + /** @var null|string|false $competitionId */ + $competitionId = $database->fetchOne( + 'SELECT competition_id FROM puzzle_solving_time WHERE id = :id', + ['id' => self::TIME_ID], + ); + + return $competitionId === false ? null : $competitionId; + } +} diff --git a/tests/Controller/PuzzleAddControllerTest.php b/tests/Controller/PuzzleAddControllerTest.php index e42d6b579..076963cc9 100644 --- a/tests/Controller/PuzzleAddControllerTest.php +++ b/tests/Controller/PuzzleAddControllerTest.php @@ -4,10 +4,15 @@ namespace SpeedPuzzling\Web\Tests\Controller; +use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\DataProvider; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionFixture; +use SpeedPuzzling\Web\Tests\DataFixtures\CompetitionSeriesFixture; use SpeedPuzzling\Web\Tests\DataFixtures\ManufacturerFixture; use SpeedPuzzling\Web\Tests\DataFixtures\PlayerFixture; +use SpeedPuzzling\Web\Tests\DataFixtures\PuzzleFixture; use SpeedPuzzling\Web\Tests\TestingLogin; +use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; final class PuzzleAddControllerTest extends WebTestCase @@ -85,4 +90,107 @@ public function testSubmitWithoutPuzzleShowsValidationError(null|string $puzzle) $this->assertResponseStatusCodeSame(422); $this->assertSelectorTextContains('form[name="puzzle_add_form"]', 'This field is required!'); } + + public function testCompetitionPickerOffersSeriesEditionsButNotUnapprovedEvents(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $crawler = $browser->request('GET', '/en/puzzle-add'); + $this->assertResponseIsSuccessful(); + + $tomSelectOptions = $crawler + ->filter('#puzzle_add_form_competition') + ->attr('data-symfony--ux-autocomplete--autocomplete-tom-select-options-value'); + self::assertNotNull($tomSelectOptions); + + self::assertStringContainsString(CompetitionSeriesFixture::EDITION_EJJ_68, $tomSelectOptions); + self::assertStringContainsString(CompetitionSeriesFixture::SERIES_EJJ, $tomSelectOptions); + self::assertStringContainsString(CompetitionFixture::COMPETITION_WJPC_2024, $tomSelectOptions); + self::assertStringNotContainsString(CompetitionFixture::COMPETITION_UNAPPROVED, $tomSelectOptions); + self::assertStringNotContainsString(CompetitionSeriesFixture::EDITION_UNAPPROVED_1, $tomSelectOptions); + } + + public function testSubmitWithNotSelectableCompetitionIsRejected(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $database = self::getContainer()->get(Connection::class); + $timesBefore = $this->countPlayerTimes($database); + + $browser->request('POST', '/en/puzzle-add', [ + 'puzzle_add_form' => $this->validSpeedPuzzlingSubmission($browser, CompetitionFixture::COMPETITION_UNAPPROVED), + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertSelectorTextContains('form[name="puzzle_add_form"]', "This competition or event can't be selected"); + self::assertSame($timesBefore, $this->countPlayerTimes($database)); + } + + public function testSubmitLinksTheTimeToASeriesEdition(): void + { + $browser = self::createClient(); + + TestingLogin::asPlayer($browser, PlayerFixture::PLAYER_REGULAR); + + $database = self::getContainer()->get(Connection::class); + $timesBefore = $this->countPlayerTimes($database); + + $browser->request('POST', '/en/puzzle-add', [ + 'puzzle_add_form' => $this->validSpeedPuzzlingSubmission($browser, CompetitionSeriesFixture::EDITION_EJJ_68), + ]); + + $this->assertResponseRedirects(); + $location = $browser->getResponse()->headers->get('Location'); + self::assertNotNull($location); + self::assertStringStartsWith('/en/time-added/', $location); + + self::assertSame($timesBefore + 1, $this->countPlayerTimes($database)); + + $timeId = substr($location, strlen('/en/time-added/')); + self::assertSame( + CompetitionSeriesFixture::EDITION_EJJ_68, + $database->fetchOne('SELECT competition_id FROM puzzle_solving_time WHERE id = :id', ['id' => $timeId]), + ); + } + + /** + * @return array + */ + private function validSpeedPuzzlingSubmission(KernelBrowser $browser, string $competitionId): array + { + $crawler = $browser->request('GET', '/en/puzzle-add'); + $this->assertResponseIsSuccessful(); + + $csrfToken = $crawler->filter('input[name="puzzle_add_form[_token]"]')->attr('value'); + self::assertNotNull($csrfToken); + + return [ + '_token' => $csrfToken, + 'mode' => 'speed_puzzling', + 'brand' => ManufacturerFixture::MANUFACTURER_RAVENSBURGER, + 'puzzle' => PuzzleFixture::PUZZLE_500_01, + 'timeHours' => '1', + 'timeMinutes' => '7', + 'timeSeconds' => '0', + 'finishedAt' => '12.07.2026', + 'firstAttempt' => '1', + 'competition' => $competitionId, + 'collection' => '__system_collection__', + ]; + } + + private function countPlayerTimes(Connection $database): int + { + /** @var int|string $count */ + $count = $database->fetchOne( + 'SELECT COUNT(*) FROM puzzle_solving_time WHERE player_id = :playerId', + ['playerId' => PlayerFixture::PLAYER_REGULAR], + ); + + return (int) $count; + } } diff --git a/tests/MessageHandler/AddPuzzleSolvingTimeHandlerTest.php b/tests/MessageHandler/AddPuzzleSolvingTimeHandlerTest.php index 2317e8ae5..57c3d5993 100644 --- a/tests/MessageHandler/AddPuzzleSolvingTimeHandlerTest.php +++ b/tests/MessageHandler/AddPuzzleSolvingTimeHandlerTest.php @@ -125,7 +125,9 @@ public function testAddSuspiciouslyFastTimeIsRejected(): void public function testUnknownCompetitionIdIsSilentlyDroppedWithoutFailing(): void { - // Controller passes user-selectable competitionId; a stale value shouldn't crash the whole save. + // The form validates the competition id against the picker's set, so an unknown id only reaches + // the handler when the competition vanished between render and submit. The time is still saved + // (without the link) and a warning is logged - a stale value must not crash the whole save. $timeId = Uuid::uuid7(); $this->messageBus->dispatch(new AddPuzzleSolvingTime( diff --git a/tests/Query/GetCompetitionEventsTest.php b/tests/Query/GetCompetitionEventsTest.php index 4a52d0c6c..930f503b9 100644 --- a/tests/Query/GetCompetitionEventsTest.php +++ b/tests/Query/GetCompetitionEventsTest.php @@ -51,8 +51,8 @@ public function testRejectedCompetitionExcludedFromUnapprovedList(): void public function testSeriesEditionsExcludedFromUpcomingAndPast(): void { - $upcoming = $this->query->allUpcoming(); - $past = $this->query->allPast(); + $upcoming = $this->query->search('upcoming'); + $past = $this->query->search('past'); $upcomingIds = array_map(static fn($c) => $c->id, $upcoming); $pastIds = array_map(static fn($c) => $c->id, $past); diff --git a/tests/Query/GetSelectableCompetitionsTest.php b/tests/Query/GetSelectableCompetitionsTest.php new file mode 100644 index 000000000..01e9afbdb --- /dev/null +++ b/tests/Query/GetSelectableCompetitionsTest.php @@ -0,0 +1,238 @@ +query = self::getContainer()->get(GetSelectableCompetitions::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testApprovedStandaloneCompetitionsAreSelectableRegardlessOfDate(): void + { + $byId = $this->byId($this->query->all()); + + // upcoming + self::assertArrayHasKey(CompetitionFixture::COMPETITION_WJPC_2024, $byId); + self::assertArrayHasKey(CompetitionFixture::COMPETITION_CZECH_NATIONALS_2024, $byId); + self::assertSame('upcoming', $byId[CompetitionFixture::COMPETITION_WJPC_2024]->eventStatus); + self::assertNull($byId[CompetitionFixture::COMPETITION_WJPC_2024]->seriesId); + + // live + self::assertArrayHasKey(CompetitionFixture::COMPETITION_RECURRING_ONLINE, $byId); + self::assertSame('live', $byId[CompetitionFixture::COMPETITION_RECURRING_ONLINE]->eventStatus); + } + + public function testUnapprovedAndRejectedStandaloneCompetitionsAreNotSelectable(): void + { + $this->insertStandalone(self::REJECTED_STANDALONE, 'Rejected Standalone', approved: true, rejected: true); + + $byId = $this->byId($this->query->all()); + + self::assertArrayNotHasKey(CompetitionFixture::COMPETITION_UNAPPROVED, $byId); + self::assertArrayNotHasKey(self::REJECTED_STANDALONE, $byId); + self::assertArrayNotHasKey(CompetitionApiFixture::COMPETITION_API_REJECTED, $byId); + } + + public function testEditionsOfApprovedSeriesAreSelectableWithSeriesData(): void + { + $byId = $this->byId($this->query->all()); + + $expectedSeries = [ + CompetitionSeriesFixture::EDITION_EJJ_68 => CompetitionSeriesFixture::SERIES_EJJ, + CompetitionSeriesFixture::EDITION_EJJ_69 => CompetitionSeriesFixture::SERIES_EJJ, + CompetitionSeriesFixture::EDITION_OFFLINE_1 => CompetitionSeriesFixture::SERIES_OFFLINE, + CompetitionSeriesFixture::EDITION_PAST_ONLY_1 => CompetitionSeriesFixture::SERIES_PAST_ONLY, + ]; + + foreach ($expectedSeries as $editionId => $seriesId) { + self::assertArrayHasKey($editionId, $byId); + self::assertSame($seriesId, $byId[$editionId]->seriesId); + self::assertNotNull($byId[$editionId]->seriesName); + } + + self::assertSame('Euro Jigsaw Jam', $byId[CompetitionSeriesFixture::EDITION_EJJ_68]->seriesName); + self::assertSame('past', $byId[CompetitionSeriesFixture::EDITION_EJJ_68]->eventStatus); + self::assertSame('upcoming', $byId[CompetitionSeriesFixture::EDITION_OFFLINE_1]->eventStatus); + // Location falls back to the series when the edition has none + self::assertSame('Prague', $byId[CompetitionSeriesFixture::EDITION_OFFLINE_1]->location); + + // Editions are never approved individually - the series approval governs them + self::assertNull($this->database->fetchOne( + 'SELECT approved_at FROM competition WHERE id = :id', + ['id' => CompetitionSeriesFixture::EDITION_EJJ_68], + )); + } + + public function testEditionOfUnapprovedSeriesIsNotSelectable(): void + { + $byId = $this->byId($this->query->all()); + + self::assertArrayNotHasKey(CompetitionSeriesFixture::EDITION_UNAPPROVED_1, $byId); + } + + public function testEditionOfRejectedSeriesIsNotSelectable(): void + { + self::assertArrayHasKey(CompetitionSeriesFixture::EDITION_PAST_ONLY_1, $this->byId($this->query->all())); + + $this->database->executeStatement( + 'UPDATE competition_series SET rejected_at = now() WHERE id = :seriesId', + ['seriesId' => CompetitionSeriesFixture::SERIES_PAST_ONLY], + ); + + self::assertArrayNotHasKey(CompetitionSeriesFixture::EDITION_PAST_ONLY_1, $this->byId($this->query->all())); + } + + public function testRejectedEditionIsNotSelectableEvenWhenItsSeriesIsApproved(): void + { + $this->database->executeStatement( + 'UPDATE competition SET rejected_at = now() WHERE id = :id', + ['id' => CompetitionSeriesFixture::EDITION_EJJ_69], + ); + + $byId = $this->byId($this->query->all()); + + self::assertArrayNotHasKey(CompetitionSeriesFixture::EDITION_EJJ_69, $byId); + self::assertArrayHasKey(CompetitionSeriesFixture::EDITION_EJJ_68, $byId); + } + + public function testGlobalOrderIsLiveThenUndatedStandaloneThenPastDescThenUpcomingAsc(): void + { + // Pin the live anchor so the assertion does not depend on how old the cached fixture database is + $this->database->executeStatement( + "UPDATE competition SET date_from = now(), date_to = now() + INTERVAL '2 days' WHERE id = :id", + ['id' => CompetitionFixture::COMPETITION_RECURRING_ONLINE], + ); + $this->insertStandalone(self::UNDATED_STANDALONE, 'Perpetual Online Jam', approved: true, rejected: false); + + $ids = array_map(static fn (SelectableCompetition $c): string => $c->id, $this->query->all()); + $index = array_flip($ids); + + self::assertLessThan($index[self::UNDATED_STANDALONE], $index[CompetitionFixture::COMPETITION_RECURRING_ONLINE]); + self::assertLessThan($index[CompetitionSeriesFixture::EDITION_EJJ_68], $index[self::UNDATED_STANDALONE]); + // past: newest first (-30 days before -45 days) + self::assertLessThan($index[CompetitionSeriesFixture::EDITION_PAST_ONLY_1], $index[CompetitionSeriesFixture::EDITION_EJJ_68]); + // upcoming after past, soonest first (+14 < +30 < +60 days) + self::assertLessThan($index[CompetitionSeriesFixture::EDITION_OFFLINE_1], $index[CompetitionSeriesFixture::EDITION_PAST_ONLY_1]); + self::assertLessThan($index[CompetitionFixture::COMPETITION_WJPC_2024], $index[CompetitionSeriesFixture::EDITION_OFFLINE_1]); + self::assertLessThan($index[CompetitionFixture::COMPETITION_CZECH_NATIONALS_2024], $index[CompetitionFixture::COMPETITION_WJPC_2024]); + } + + public function testUndatedEditionWithRoundsIsDatedByItsFirstRound(): void + { + $this->database->executeStatement( + << self::UNDATED_EDITION_WITH_ROUND, 'seriesId' => CompetitionSeriesFixture::SERIES_EJJ], + ); + $this->database->executeStatement( + << self::UNDATED_EDITION_ROUND, 'competitionId' => self::UNDATED_EDITION_WITH_ROUND], + ); + + $all = $this->query->all(); + $byId = $this->byId($all); + $index = array_flip(array_map(static fn (SelectableCompetition $c): string => $c->id, $all)); + + self::assertArrayHasKey(self::UNDATED_EDITION_WITH_ROUND, $byId); + self::assertNull($byId[self::UNDATED_EDITION_WITH_ROUND]->dateFrom); + self::assertSame('past', $byId[self::UNDATED_EDITION_WITH_ROUND]->eventStatus); + // -40 days sits between EJJ #68 (-30 days) and Berlin Puzzle Cup 2026 (-45 days) + self::assertLessThan($index[self::UNDATED_EDITION_WITH_ROUND], $index[CompetitionSeriesFixture::EDITION_EJJ_68]); + self::assertLessThan($index[CompetitionSeriesFixture::EDITION_PAST_ONLY_1], $index[self::UNDATED_EDITION_WITH_ROUND]); + } + + public function testUndatedEditionWithoutRoundsSortsLast(): void + { + $this->database->executeStatement( + << self::UNDATED_EDITION_WITH_ROUND, 'seriesId' => CompetitionSeriesFixture::SERIES_EJJ], + ); + + $all = $this->query->all(); + $last = end($all); + + self::assertNotFalse($last); + self::assertSame(self::UNDATED_EDITION_WITH_ROUND, $last->id); + self::assertSame('undated', $last->eventStatus); + } + + public function testAlwaysIncludedCompetitionIsReturnedExactlyOnce(): void + { + // Not selectable on its own (unapproved) - included for the edit form of a time linked to it + $ids = array_map(static fn (SelectableCompetition $c): string => $c->id, $this->query->all(CompetitionFixture::COMPETITION_UNAPPROVED)); + self::assertCount(1, array_keys($ids, CompetitionFixture::COMPETITION_UNAPPROVED, true)); + + // Already selectable - must not be duplicated + $ids = array_map(static fn (SelectableCompetition $c): string => $c->id, $this->query->all(CompetitionFixture::COMPETITION_WJPC_2024)); + self::assertCount(1, array_keys($ids, CompetitionFixture::COMPETITION_WJPC_2024, true)); + } + + public function testInvalidAlwaysIncludedIdIsIgnored(): void + { + $withInvalid = $this->query->all('not-a-uuid'); + $plain = $this->query->all(); + + self::assertCount(count($plain), $withInvalid); + } + + /** + * @param list $competitions + * @return array + */ + private function byId(array $competitions): array + { + $byId = []; + + foreach ($competitions as $competition) { + $byId[$competition->id] = $competition; + } + + return $byId; + } + + private function insertStandalone(string $id, string $name, bool $approved, bool $rejected): void + { + $this->database->executeStatement( + << $id, + 'name' => $name, + 'approvedAt' => $approved ? '2026-01-01 00:00:00' : null, + 'rejectedAt' => $rejected ? '2026-01-02 00:00:00' : null, + ], + ); + } +} diff --git a/tests/Services/CompetitionChoicesBuilderTest.php b/tests/Services/CompetitionChoicesBuilderTest.php new file mode 100644 index 000000000..f3e104a9a --- /dev/null +++ b/tests/Services/CompetitionChoicesBuilderTest.php @@ -0,0 +1,170 @@ +builder = self::getContainer()->get(CompetitionChoicesBuilder::class); + $this->database = self::getContainer()->get(Connection::class); + } + + public function testEditionsAreGroupedUnderTheirSeries(): void + { + $choices = $this->builder->build(); + + $edition = $this->option($choices, CompetitionSeriesFixture::EDITION_EJJ_68); + self::assertSame(CompetitionSeriesFixture::SERIES_EJJ, $edition['optgroup'] ?? null); + + $standalone = $this->option($choices, CompetitionFixture::COMPETITION_WJPC_2024); + self::assertArrayNotHasKey('optgroup', $standalone); + + $optgroupsByValue = []; + foreach ($choices->optgroups as $optgroup) { + $optgroupsByValue[$optgroup['value']] = $optgroup; + } + + self::assertArrayHasKey(CompetitionSeriesFixture::SERIES_EJJ, $optgroupsByValue); + self::assertSame('Euro Jigsaw Jam', $optgroupsByValue[CompetitionSeriesFixture::SERIES_EJJ]['label']); + self::assertArrayHasKey(CompetitionSeriesFixture::SERIES_OFFLINE, $optgroupsByValue); + self::assertArrayHasKey(CompetitionSeriesFixture::SERIES_PAST_ONLY, $optgroupsByValue); + // Exactly one optgroup per series, no matter how many editions it has + self::assertCount(count($optgroupsByValue), $choices->optgroups); + } + + public function testUnapprovedSeriesHasNoOptgroupAndItsEditionIsNotOffered(): void + { + $choices = $this->builder->build(); + + foreach ($choices->optgroups as $optgroup) { + self::assertNotSame(CompetitionSeriesFixture::SERIES_UNAPPROVED, $optgroup['value']); + } + + self::assertFalse($choices->contains(CompetitionSeriesFixture::EDITION_UNAPPROVED_1)); + } + + public function testContainsReflectsExactlyTheOfferedOptions(): void + { + $choices = $this->builder->build(); + + self::assertTrue($choices->contains(CompetitionFixture::COMPETITION_WJPC_2024)); + self::assertTrue($choices->contains(CompetitionSeriesFixture::EDITION_EJJ_69)); + self::assertFalse($choices->contains(CompetitionFixture::COMPETITION_UNAPPROVED)); + self::assertFalse($choices->contains('not-a-uuid')); + + $withCurrent = $this->builder->build(CompetitionFixture::COMPETITION_UNAPPROVED); + + self::assertTrue($withCurrent->contains(CompetitionFixture::COMPETITION_UNAPPROVED)); + $values = array_column($withCurrent->options, 'value'); + self::assertCount(1, array_keys($values, CompetitionFixture::COMPETITION_UNAPPROVED, true)); + } + + public function testOrganiserAuthoredStringsAreEscaped(): void + { + $this->database->executeStatement( + <<x', 'Nowhere & "there"', false, now()) + SQL, + ['id' => self::HTML_NAMED_COMPETITION], + ); + + $option = $this->option($this->builder->build(), self::HTML_NAMED_COMPETITION); + + self::assertStringContainsString('<b>x</b>', $option['text']); + self::assertStringNotContainsString('x', $option['text']); + self::assertStringContainsString('<i>Nowhere</i> & "there"', $option['text']); + // keywords are plain text - TomSelect matches them as-is, they are never rendered + self::assertSame('x Nowhere & "there"', $option['keywords']); + } + + public function testKeywordsCarrySeriesAndEditionNames(): void + { + $option = $this->option($this->builder->build(), CompetitionSeriesFixture::EDITION_EJJ_68); + + self::assertStringContainsString('Euro Jigsaw Jam', $option['keywords']); + self::assertStringContainsString('EJJ #68', $option['keywords']); + // The card itself names the series so the selected item stays self-descriptive + self::assertStringContainsString('Euro Jigsaw Jam', $option['text']); + self::assertStringContainsString('competition-option', $option['text']); + } + + public function testLogoIsLazyLoadedAndFallsBackToTheSeriesLogo(): void + { + $this->database->executeStatement( + 'UPDATE competition_series SET logo = :logo WHERE id = :id', + ['logo' => 'competitions/ejj-series.png', 'id' => CompetitionSeriesFixture::SERIES_EJJ], + ); + $this->database->executeStatement( + 'UPDATE competition SET logo = :logo WHERE id = :id', + ['logo' => 'competitions/wjpc.png', 'id' => CompetitionFixture::COMPETITION_WJPC_2024], + ); + + $choices = $this->builder->build(); + + $standalone = $this->option($choices, CompetitionFixture::COMPETITION_WJPC_2024); + self::assertStringContainsString('loading="lazy"', $standalone['text']); + self::assertStringContainsString('competition-option-logo', $standalone['text']); + self::assertStringContainsString('competitions/wjpc.png', $standalone['text']); + + $edition = $this->option($choices, CompetitionSeriesFixture::EDITION_EJJ_68); + self::assertStringContainsString('competitions/ejj-series.png', $edition['text']); + self::assertStringContainsString('loading="lazy"', $edition['text']); + + $ejjOptgroup = null; + foreach ($choices->optgroups as $optgroup) { + if ($optgroup['value'] === CompetitionSeriesFixture::SERIES_EJJ) { + $ejjOptgroup = $optgroup; + } + } + self::assertNotNull($ejjOptgroup); + self::assertStringContainsString('competitions/ejj-series.png', $ejjOptgroup['logo'] ?? ''); + + // No logo anywhere - no at all + $czech = $this->option($choices, CompetitionFixture::COMPETITION_CZECH_NATIONALS_2024); + self::assertStringNotContainsString('database->executeStatement( + "UPDATE competition SET date_from = now(), date_to = now() + INTERVAL '2 days' WHERE id = :id", + ['id' => CompetitionFixture::COMPETITION_RECURRING_ONLINE], + ); + + $choices = $this->builder->build(); + + self::assertStringContainsString('>live', $this->option($choices, CompetitionFixture::COMPETITION_RECURRING_ONLINE)['text']); + self::assertStringNotContainsString('>live', $this->option($choices, CompetitionFixture::COMPETITION_WJPC_2024)['text']); + } + + /** + * @return array{value: string, text: string, keywords: string, optgroup?: string} + */ + private function option(CompetitionChoices $choices, string $competitionId): array + { + foreach ($choices->options as $option) { + if ($option['value'] === $competitionId) { + return $option; + } + } + + self::fail(sprintf('Option %s is not offered', $competitionId)); + } +} diff --git a/translations/messages.en.yml b/translations/messages.en.yml index d45f62474..2cd46af34 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -386,6 +386,9 @@ forms: tag: "Tag" competition: "Competition / event" competition_help: "Choose competition, when this result is from a competition or an online event." + competition_hint_series: "For recurring events, pick the specific edition (date) you took part in." + competition_live_badge: "live" + competition_not_selectable: "This competition or event can't be selected. Please pick one from the list." tags: "Tags" feedback: "Message" feedback_help: "Send us suggestion of new feature or improvement, bug report, what you (dis)like, it can be anything that comes to your mind..."