Compare commits

...

1 Commits

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

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

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

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

* Explicitly disallow unsafe link protocols in rendered markdown

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

* Autolink bare URLs in rendered release notes

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

* Address review feedback on GitHubReleasesService and tests

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

* Address second round of CodeRabbit feedback

- Escape raw HTML embedded in release-note markdown instead of passing
  it through, via twig_extra.commonmark.html_input: escape.
- Don't cache GitHub API failures at all (set $save = false in the
  cache callback) instead of caching them for a short TTL, so the very
  next request retries immediately.
- Sort releases by published_at descending before mapping, so the
  first entry (used for the current-version badge and default-expanded
  accordion item) is guaranteed to be the newest regardless of the
  order GitHub's API happens to return.
2026-07-12 18:59:36 +00:00
14 changed files with 841 additions and 10 deletions
+6
View File
@@ -3,3 +3,9 @@
.col-result-md { width: 20%; }
.modal-content > turbo-frame { display: contents; }
.release-notes {
overflow-wrap: break-word;
> :last-child { margin-bottom: 0; }
}
+3
View File
@@ -13,6 +13,7 @@
"doctrine/doctrine-bundle": "^3.2.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.2",
"league/commonmark": "^2.7",
"martin-georgiev/postgresql-for-doctrine": "^4.4",
"phpdocumentor/reflection-docblock": "^6.0.3",
"phpoffice/phpspreadsheet": "^5.5",
@@ -27,6 +28,7 @@
"symfony/flex": "^2.11.0",
"symfony/form": "8.1.*",
"symfony/framework-bundle": "8.1.*",
"symfony/http-client": "8.1.*",
"symfony/mailer": "8.1.*",
"symfony/object-mapper": "8.1.*",
"symfony/property-access": "8.1.*",
@@ -48,6 +50,7 @@
"thecodingmachine/safe": "^3.4.0",
"twig/extra-bundle": "^3.24.0",
"twig/intl-extra": "^3.24.0",
"twig/markdown-extra": "^3.24.0",
"twig/twig": "^3.27.1"
},
"require-dev": {
Generated
+495 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5",
"content-hash": "519e76dd9df3bca43c3e32e347c600e7",
"packages": [
{
"name": "composer/pcre",
@@ -159,6 +159,81 @@
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
"scrutinizer/ocular": "1.6.0",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Dflydev\\DotAccessData\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dragonfly Development Inc.",
"email": "info@dflydev.com",
"homepage": "http://dflydev.com"
},
{
"name": "Beau Simensen",
"email": "beau@dflydev.com",
"homepage": "http://beausimensen.com"
},
{
"name": "Carlos Frutos",
"email": "carlos@kiwing.it",
"homepage": "https://github.com/cfrutos"
},
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com"
}
],
"description": "Given a deep data structure, access data by dot notation.",
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
"keywords": [
"access",
"data",
"dot",
"notation"
],
"support": {
"issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
"source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/collections",
"version": "2.6.0",
@@ -1652,6 +1727,195 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"league/config": "^1.1.1",
"php": "^7.4 || ^8.0",
"psr/event-dispatcher": "^1.0",
"symfony/deprecation-contracts": "^2.1 || ^3.0",
"symfony/polyfill-php80": "^1.16"
},
"require-dev": {
"cebe/markdown": "^1.0",
"commonmark/cmark": "0.31.1",
"commonmark/commonmark.js": "0.31.1",
"composer/package-versions-deprecated": "^1.8",
"embed/embed": "^4.4",
"erusev/parsedown": "^1.0",
"ext-json": "*",
"github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^2.0.0",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.9-dev"
}
},
"autoload": {
"psr-4": {
"League\\CommonMark\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
"homepage": "https://commonmark.thephpleague.com",
"keywords": [
"commonmark",
"flavored",
"gfm",
"github",
"github-flavored",
"markdown",
"md",
"parser"
],
"support": {
"docs": "https://commonmark.thephpleague.com/",
"forum": "https://github.com/thephpleague/commonmark/discussions",
"issues": "https://github.com/thephpleague/commonmark/issues",
"rss": "https://github.com/thephpleague/commonmark/releases.atom",
"source": "https://github.com/thephpleague/commonmark"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
"type": "tidelift"
}
],
"time": "2026-07-12T15:29:16+00:00"
},
{
"name": "league/config",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/config.git",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"shasum": ""
},
"require": {
"dflydev/dot-access-data": "^3.0.1",
"nette/schema": "^1.2",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.5",
"scrutinizer/ocular": "^1.8.1",
"unleashedtech/php-coding-standard": "^3.1",
"vimeo/psalm": "^4.7.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\Config\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Define configuration arrays with strict schemas and access values with dot notation",
"homepage": "https://config.thephpleague.com",
"keywords": [
"array",
"config",
"configuration",
"dot",
"dot-access",
"nested",
"schema"
],
"support": {
"docs": "https://config.thephpleague.com/",
"issues": "https://github.com/thephpleague/config/issues",
"rss": "https://github.com/thephpleague/config/releases.atom",
"source": "https://github.com/thephpleague/config"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
}
],
"time": "2022-12-11T20:36:23+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
@@ -1966,6 +2230,164 @@
],
"time": "2026-07-01T18:17:39+00:00"
},
{
"name": "nette/schema",
"version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
"php": "8.1 - 8.5"
},
"require-dev": {
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.6",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "📐 Nette Schema: validating data structures against a given Schema.",
"homepage": "https://nette.org",
"keywords": [
"config",
"nette"
],
"support": {
"issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.5"
},
"time": "2026-02-23T03:47:12+00:00"
},
{
"name": "nette/utils",
"version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
"php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"homepage": "https://nette.org",
"keywords": [
"array",
"core",
"datetime",
"images",
"json",
"nette",
"paginator",
"password",
"slugify",
"string",
"unicode",
"utf-8",
"utility",
"validation"
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.4"
},
"time": "2026-05-11T20:49:54+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -8711,6 +9133,78 @@
],
"time": "2026-05-19T20:44:48+00:00"
},
{
"name": "twig/markdown-extra",
"version": "v3.28.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/markdown-extra.git",
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
"shasum": ""
},
"require": {
"php": ">=8.1.0",
"symfony/deprecation-contracts": "^2.5|^3",
"twig/twig": "^3.13|^4.0"
},
"require-dev": {
"erusev/parsedown": "dev-master as 1.x-dev",
"league/commonmark": "^2.7",
"league/html-to-markdown": "^4.8|^5.0",
"michelf/php-markdown": "^1.8|^2.0",
"symfony/phpunit-bridge": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Twig\\Extra\\Markdown\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Twig extension for Markdown",
"homepage": "https://twig.symfony.com",
"keywords": [
"html",
"markdown",
"twig"
],
"support": {
"source": "https://github.com/twigphp/markdown-extra/tree/v3.28.0"
},
"funding": [
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
"type": "tidelift"
}
],
"time": "2026-06-05T19:47:22+00:00"
},
{
"name": "twig/twig",
"version": "v3.28.0",
+1
View File
@@ -30,6 +30,7 @@ security:
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/backoffice/releases$, roles: PUBLIC_ACCESS }
- { path: ^/backoffice, roles: IS_AUTHENTICATED }
when@test:
+8
View File
@@ -0,0 +1,8 @@
twig_extra:
commonmark:
allow_unsafe_links: false
html_input: escape
services:
League\CommonMark\Extension\Autolink\AutolinkExtension:
tags: ['twig.markdown.league_extension']
+1 -1
View File
@@ -1271,7 +1271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false
* },
* markdown?: bool|array{
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* },
* intl?: bool|array{
* enabled?: bool|Param, // Default: true
+4
View File
@@ -33,6 +33,10 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi
fi
# var/ is a Docker volume that survives redeploys, so cache.app (e.g. the GitHub releases cache)
# can carry stale entries from the previous image into the new one unless cleared here.
php bin/console cache:pool:clear cache.app
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Tvdt\Controller\AbstractController;
use Tvdt\Service\GitHubReleasesService;
final class ReleasesController extends AbstractController
{
public function __construct(
private readonly GitHubReleasesService $gitHubReleasesService,
) {}
#[Route('/backoffice/releases', name: 'tvdt_backoffice_releases', methods: ['GET'])]
public function index(): Response
{
return $this->render('backoffice/releases/_frame.html.twig', [
'releases' => $this->gitHubReleasesService->getReleases(),
]);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Psr\Cache\InvalidArgumentException;
use Safe\DateTimeImmutable;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class GitHubReleasesService
{
private const string RELEASES_URL = 'https://api.github.com/repos/MarijnDoeve/TijdVoorDeTest/releases';
public function __construct(
private HttpClientInterface $httpClient,
private CacheInterface $cache,
) {}
/**
* @throws InvalidArgumentException
*
* @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}>
*/
public function getReleases(): array
{
return $this->cache->get('github_releases', $this->fetchReleases(...));
}
/** @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}> */
private function fetchReleases(ItemInterface $item, bool &$save): array
{
try {
$response = $this->httpClient->request('GET', self::RELEASES_URL, [
'timeout' => 5,
'headers' => [
'Accept' => 'application/vnd.github+json',
'User-Agent' => 'TijdVoorDeTest',
],
]);
/** @var list<array{tag_name: string, name: ?string, published_at: ?string, body: ?string, html_url: string}> $releases */
$releases = $response->toArray();
} catch (ExceptionInterface) {
$save = false;
return [];
}
$item->expiresAfter(3600);
usort($releases, static fn (array $a, array $b): int => ($b['published_at'] ?? '') <=> ($a['published_at'] ?? ''));
return array_map(static fn (array $release): array => [
'tagName' => $release['tag_name'],
'name' => $release['name'] ?: $release['tag_name'],
'publishedAt' => $release['published_at'] ? new DateTimeImmutable($release['published_at']) : null,
'body' => (string) $release['body'],
'url' => $release['html_url'],
], $releases);
}
}
+27 -8
View File
@@ -10,9 +10,9 @@
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% if is_granted('IS_AUTHENTICATED') %}
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a>
@@ -21,8 +21,27 @@
<a class="nav-link"
href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a>
</li>
</ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0">
{% endif %}
</ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0">
<li class="nav-item" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
<button type="button" class="nav-link border-0 bg-transparent"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_releases') }}"
data-modal-title="{{ 'Releases'|trans }}">{{ 'Releases'|trans }}</button>
<div class="modal fade" tabindex="-1" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="releasesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<turbo-frame id="releases-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
</li>
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
@@ -31,8 +50,8 @@
<a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
</li>
</ul>
</div>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
@@ -0,0 +1,40 @@
<turbo-frame id="releases-modal-frame">
<div class="modal-header">
<h1 class="modal-title fs-5" id="releasesModalLabel">
{{ 'Releases'|trans }}
{% if releases[0] is defined %}
<span class="text-muted fs-6 ms-2">{{ 'Current version'|trans }}: {{ releases[0].tagName }}</span>
{% endif %}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if releases is empty %}
<p class="text-muted mb-0">{{ 'Could not load releases from GitHub.'|trans }}</p>
{% else %}
<div class="accordion" id="releasesAccordion">
{% for release in releases %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button{% if not loop.first %} collapsed{% endif %}" type="button"
data-bs-toggle="collapse" data-bs-target="#release-{{ loop.index }}">
{{ release.name }}
{% if release.publishedAt %}
<span class="text-muted ms-2 small">{{ release.publishedAt|date('d-m-Y') }}</span>
{% endif %}
</button>
</h2>
<div id="release-{{ loop.index }}"
class="accordion-collapse collapse{% if loop.first %} show{% endif %}"
data-bs-parent="#releasesAccordion">
<div class="accordion-body">
<div class="release-notes">{{ release.body|markdown_to_html }}</div>
<a href="{{ release.url }}" target="_blank" rel="noopener noreferrer" class="small">{{ 'View on GitHub'|trans }}</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</turbo-frame>
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\ReleasesController;
use Tvdt\Service\GitHubReleasesService;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(ReleasesController::class)]
final class ReleasesControllerTest extends AbstractControllerWebTestCase
{
public function testReleasesFrameRendersReleaseNotes(): void
{
$this->loginAs('user2@example.org');
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'v0.8.0');
self::assertSelectorTextContains('body', 'Some release notes');
self::assertSelectorTextContains('#releasesModalLabel', 'Huidige versie: v0.8.0');
}
public function testReleasesFrameIsAccessibleWithoutAuthentication(): void
{
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
}
private function mockReleasesService(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
self::getContainer()->set(GitHubReleasesService::class, new GitHubReleasesService($httpClient, new ArrayAdapter()));
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Safe\DateTimeImmutable;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Tvdt\Service\GitHubReleasesService;
#[CoversClass(GitHubReleasesService::class)]
final class GitHubReleasesServiceTest extends TestCase
{
public function testGetReleasesMapsGitHubResponse(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => "## Added\n- Something new",
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertEquals([
'tagName' => 'v0.8.0',
'name' => 'v0.8.0',
'publishedAt' => new DateTimeImmutable('2026-07-12T10:00:00Z'),
'body' => "## Added\n- Something new",
'url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
], $releases[0]);
}
public function testGetReleasesReturnsEmptyArrayOnHttpFailure(): void
{
$httpClient = new MockHttpClient(static fn (): MockResponse => new MockResponse('', ['http_code' => 500]));
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$this->assertSame([], $subject->getReleases());
}
public function testHttpFailureIsNotCached(): void
{
$requestCount = 0;
$httpClient = new MockHttpClient(static function () use (&$requestCount): MockResponse {
++$requestCount;
return new MockResponse('', ['http_code' => 500]);
});
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$subject->getReleases();
$subject->getReleases();
$this->assertSame(2, $requestCount);
}
public function testGetReleasesSortsNewestFirst(): void
{
$body = json_encode([
[
'tag_name' => 'v0.7.0',
'name' => 'v0.7.0',
'published_at' => '2026-07-10T10:00:00Z',
'body' => 'Older release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.7.0',
],
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Newer release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertSame('v0.8.0', $releases[0]['tagName']);
$this->assertSame('v0.7.0', $releases[1]['tagName']);
}
}
+16
View File
@@ -241,6 +241,10 @@
<source>Could not find candidate with name {name} in elimination.</source>
<target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target>
</trans-unit>
<trans-unit id="HL5QYZd" resname="Could not load releases from GitHub.">
<source>Could not load releases from GitHub.</source>
<target>Kan releases niet laden van GitHub.</target>
</trans-unit>
<trans-unit id="0DvmToq" resname="Create a season">
<source>Create a season</source>
<target>Maak een seizoen aan</target>
@@ -265,6 +269,10 @@
<source>Current password</source>
<target>Huidig wachtwoord</target>
</trans-unit>
<trans-unit id="xreXHN5" resname="Current version">
<source>Current version</source>
<target>Huidige versie</target>
</trans-unit>
<trans-unit id="PkrbQOH" resname="Cyan">
<source>Cyan</source>
<target>Cyaan</target>
@@ -745,6 +753,10 @@
<source>Register</source>
<target>Registreren</target>
</trans-unit>
<trans-unit id="3s37xTt" resname="Releases">
<source>Releases</source>
<target>Releases</target>
</trans-unit>
<trans-unit id="WevL4T_" resname="Remember me">
<source>Remember me</source>
<target>Onthoud mij</target>
@@ -977,6 +989,10 @@
<source>View</source>
<target>Bekijken</target>
</trans-unit>
<trans-unit id="a1.g0sm" resname="View on GitHub">
<source>View on GitHub</source>
<target>Bekijk op GitHub</target>
</trans-unit>
<trans-unit id="JWRtx_o" resname="White">
<source>White</source>
<target>Wit</target>