diff --git a/docker-compose.yml b/docker-compose.yml index 5dba34142f..c515ced4da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -210,6 +210,8 @@ services: test: [CMD, curl, -f, http://localhost:7700/health] volumes: - meilisearch_data:/meili_data + ports: + - "7700:7700" mysql: image: mariadb:11 diff --git a/docs/docs/hosting/hub-setup.mdx b/docs/docs/hosting/hub-setup.mdx index c08e527909..7e367ed683 100644 --- a/docs/docs/hosting/hub-setup.mdx +++ b/docs/docs/hosting/hub-setup.mdx @@ -43,3 +43,19 @@ having to go through the email verification process. :::caution Only enable this if you trust a login provider completely. ::: + +### `FEATURE_LIST_POLLING_INTERVAL` + +Configures the background polling interval used by content listing views (such as search and user content lists) to dynamically update edit lock statuses across displayed items via HTMX. + +* **Default**: `5s` +* **Format**: A time duration string (e.g. `5s`, `10s`, `1m`) or numeric seconds (e.g. `5`, `10`). +* **Disabling**: Set to `false`, `0`, `none`, or `disabled` to disable list polling entirely and minimize server requests. + +### `FEATURE_DETAILS_POLLING_INTERVAL` + +Configures the background polling interval used by the content details view to dynamically update edit locks and action button states via HTMX. + +* **Default**: `5s` +* **Format**: A time duration string (e.g. `5s`, `10s`, `1m`) or numeric seconds (e.g. `5`, `10`). +* **Disabling**: Set to `false`, `0`, `none`, or `disabled` to disable details polling entirely and minimize server requests. diff --git a/sourcecode/hub/.env.example b/sourcecode/hub/.env.example index 28a3104316..1041beb155 100644 --- a/sourcecode/hub/.env.example +++ b/sourcecode/hub/.env.example @@ -11,6 +11,9 @@ FEATURE_SIGNUP_ENABLED=true FEATURE_RESET_PASSWORD_ENABLED=true FEATURE_NOINDEX=false FEATURE_SOCIAL_USERS_ARE_VERIFIED=false +FEATURE_CA_CONTENT_TYPE_DISPLAY=h5p +FEATURE_LIST_POLLING_INTERVAL=5s +FEATURE_DETAILS_POLLING_INTERVAL=5s LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null diff --git a/sourcecode/hub/.gitignore b/sourcecode/hub/.gitignore index 430994615e..003c4dee2d 100644 --- a/sourcecode/hub/.gitignore +++ b/sourcecode/hub/.gitignore @@ -24,3 +24,7 @@ yarn-error.log .env.staging /.vite /tools/vapor-cli/vendor + +.junie +boost.json +AGENTS.md diff --git a/sourcecode/hub/app/Configuration/Features.php b/sourcecode/hub/app/Configuration/Features.php index e02e12b673..a2a6dd3693 100644 --- a/sourcecode/hub/app/Configuration/Features.php +++ b/sourcecode/hub/app/Configuration/Features.php @@ -33,4 +33,51 @@ public function socialUsersAreVerified(): bool { return $this->enabled('social-users-are-verified'); } + + public function getListPollingInterval(): string|null + { + return self::listPollingInterval(); + } + + public static function listPollingInterval(): string|null + { + $raw = config('features.list-polling-interval') ?? config('features.content-list-polling-interval'); + + return self::formatPollingInterval($raw); + } + + public function getDetailsPollingInterval(): string|null + { + return self::detailsPollingInterval(); + } + + public static function detailsPollingInterval(): string|null + { + $raw = config('features.details-polling-interval') ?? config('features.content-details-polling-interval'); + + return self::formatPollingInterval($raw); + } + + public static function formatPollingInterval(mixed $value): string|null + { + if ($value === null || $value === false || $value === '' || $value === 0 || $value === '0' || $value === '0s') { + return null; + } + + $str = trim((string) $value); + if ($str === '' || $str === '0' || $str === '0s' || in_array(strtolower($str), ['none', 'disabled', 'false', 'off', 'null'], true)) { + return null; + } + + if (is_numeric($str)) { + $num = (float) $str; + if ($num <= 0) { + return null; + } + + return $str . 's'; + } + + return $str; + } } diff --git a/sourcecode/hub/app/DataObjects/ContentDisplayItem.php b/sourcecode/hub/app/DataObjects/ContentDisplayItem.php index 8f6741cb9d..ad787e795b 100644 --- a/sourcecode/hub/app/DataObjects/ContentDisplayItem.php +++ b/sourcecode/hub/app/DataObjects/ContentDisplayItem.php @@ -4,7 +4,17 @@ namespace App\DataObjects; +use App\Models\Content; +use App\Models\ContentVersion; +use App\Support\SessionScope; use Carbon\CarbonImmutable; +use Illuminate\Support\Facades\Gate; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +use function app; +use function locale_get_display_language; +use function route; +use function strtoupper; readonly class ContentDisplayItem { @@ -29,5 +39,57 @@ public function __construct( public string|null $copyUrl, public string|null $deleteUrl, public string|null $lockedByUserName, + public string|null $actionButtonsUrl = null, + public string|null $id = null, ) {} + + public static function fromContent( + Content $content, + ContentVersion|null $version = null, + bool $forUser = false, + bool $showDrafts = false, + int $viewsCount = 0, + string|null $contentType = null, + bool $includeActionButtonsUrl = false, + ): self { + $version ??= ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion) + ?? $content->latestVersion + ?? throw new NotFoundHttpException(); + + $canUse = Gate::allows('use', [$content, $version]); + $canEdit = Gate::allows('edit', [$content, $version]); + $canView = Gate::allows('view', $content); + $canDelete = $forUser && Gate::allows('delete', $content); + $canCopy = Gate::allows('copy', $content); + + $languageName = locale_get_display_language($version->language_iso_639_3, app()->getLocale()); + $languageName = (!empty($languageName) && $languageName !== $version->language_iso_639_3) ? $languageName : null; + + return new self( + title: $version->title, + createdAt: $version->created_at?->toImmutable(), + isPublished: $version->published, + viewsCount: $viewsCount, + contentType: $contentType ?? $version->displayed_content_type, + languageIso639_3: strtoupper($version->language_iso_639_3), + languageDisplayName: $languageName, + users: $content->users->map(fn($user) => $user->name)->join(', '), + detailsUrl: $showDrafts ? route('content.version-details', [$content, $version]) : route('content.details', [$content]), + previewUrl: route('content.preview', [$content, $version]), + useUrl: $canUse ? route('content.use', [$content, $version]) : null, + editUrl: $canEdit ? route('content.edit', [$content, $version]) : null, + shareUrl: $canView ? route('content.share', [$content, SessionScope::TOKEN_PARAM => null]) : null, + shareDialogUrl: $canView ? route('content.share-dialog', [$content]) : null, + copyUrl: $canCopy ? route('content.copy', [$content]) : null, + deleteUrl: $canDelete ? route('content.delete', [$content]) : null, + lockedByUserName: $content->getActiveLock()?->user?->name, + actionButtonsUrl: ($includeActionButtonsUrl && $canEdit) ? route('content.action-buttons', [ + $content, + 'version' => $version->id, + 'forUser' => $forUser ? 1 : 0, + 'showDrafts' => $showDrafts ? 1 : 0, + ]) : null, + id: $content->id, + ); + } } diff --git a/sourcecode/hub/app/Http/Controllers/ContentAuthorController.php b/sourcecode/hub/app/Http/Controllers/ContentAuthorController.php index f2d65aed1d..af9faa02e3 100644 --- a/sourcecode/hub/app/Http/Controllers/ContentAuthorController.php +++ b/sourcecode/hub/app/Http/Controllers/ContentAuthorController.php @@ -168,6 +168,8 @@ public function update( }); assert($version instanceof ContentVersion); + $content->releaseLock($user); + return response()->json( [ 'id' => $version->content_id, diff --git a/sourcecode/hub/app/Http/Controllers/ContentController.php b/sourcecode/hub/app/Http/Controllers/ContentController.php index cab24c138b..c90af48e52 100644 --- a/sourcecode/hub/app/Http/Controllers/ContentController.php +++ b/sourcecode/hub/app/Http/Controllers/ContentController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; +use App\DataObjects\ContentDisplayItem; use App\DataObjects\LtiCreateInfo; use App\Enums\ContentRole; use App\Enums\ContentViewSource; @@ -30,6 +31,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Session; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -426,6 +428,8 @@ public function ltiUpdate( }); assert($version instanceof ContentVersion); + $content->releaseLock($user); + // return to platform consuming Edlib if ($request->session()->get('lti.lti_message_type') === 'ContentItemSelectionRequest') { $ltiRequest = $version->toItemSelectionRequest(); @@ -523,4 +527,99 @@ public function releaseLock(Content $content): Response return response()->noContent(); } + + public function actionButtons(Content $content, Request $request): View + { + $version = null; + if ($request->filled('version')) { + $version = $content->versions()->whereKey($request->query('version'))->first(); + } + $forUser = $request->boolean('forUser'); + $showDrafts = $request->boolean('showDrafts'); + $version ??= ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion) + ?? $content->latestVersion + ?? throw new NotFoundHttpException(); + + $this->authorize('view', [$content, $version]); + + $content->loadMissing(['users', 'locks.user']); + + $displayItem = ContentDisplayItem::fromContent( + $content, + $version, + forUser: $forUser, + showDrafts: $showDrafts, + includeActionButtonsUrl: true, + ); + + return view('components.content.action-buttons', [ + 'content' => $displayItem, + ]); + } + + public function bulkActionButtons(Request $request): View + { + $ids = (array) $request->input('ids', []); + $forUser = $request->boolean('forUser'); + $showDrafts = $request->boolean('showDrafts'); + + if (empty($ids)) { + return view('content.bulk-action-buttons', [ + 'displayItems' => collect(), + ]); + } + + $eagerLoad = ['users', 'locks.user']; + if ($showDrafts) { + $eagerLoad[] = 'latestVersion'; + } + if (!$showDrafts || $forUser) { + $eagerLoad[] = 'latestPublishedVersion'; + } + + $contents = Content::whereIn('id', $ids) + ->with($eagerLoad) + ->get(); + + $displayItems = $contents->map(function (Content $content) use ($forUser, $showDrafts) { + $version = ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion) + ?? $content->latestVersion; + + if (!$version || !Gate::allows('view', [$content, $version]) || !Gate::allows('edit', [$content, $version])) { + return null; + } + + return ContentDisplayItem::fromContent( + content: $content, + version: $version, + forUser: $forUser, + showDrafts: $showDrafts, + ); + })->filter()->values(); + + return view('content.bulk-action-buttons', [ + 'displayItems' => $displayItems, + ]); + } + + public function detailsActionButtons(Content $content, Request $request): View + { + $version = null; + if ($request->filled('version')) { + $version = $content->versions()->whereKey($request->query('version'))->first(); + } + $version ??= $content->getCachedLatestPublishedVersion() ?? $content->latestVersion ?? throw new NotFoundHttpException(); + $explicitVersion = $request->boolean('explicitVersion'); + + $this->authorize('view', [$content, $version]); + + $content->loadMissing(['locks.user']); + + return view('components.content.details.action-buttons', [ + 'content' => $content, + 'version' => $version, + 'explicitVersion' => $explicitVersion, + 'includeOob' => true, + ]); + } } diff --git a/sourcecode/hub/app/Http/Requests/ContentFilter.php b/sourcecode/hub/app/Http/Requests/ContentFilter.php index e1a11b3f81..4923709931 100644 --- a/sourcecode/hub/app/Http/Requests/ContentFilter.php +++ b/sourcecode/hub/app/Http/Requests/ContentFilter.php @@ -6,12 +6,10 @@ use App\DataObjects\ContentDisplayItem; use App\Models\Content; -use App\Support\SessionScope; use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Gate; use Illuminate\Validation\Rule; use Laravel\Scout\Builder; use Override; @@ -334,33 +332,13 @@ private function attachModel(array $hits, bool $forUser, bool $showDrafts): Coll $version = ($showDrafts ? $content->latestVersion : $content->latestPublishedVersion) ?? throw new NotFoundHttpException(); - $canUse = Gate::allows('use', [$content, $version]); - $canEdit = Gate::allows('edit', [$content, $version]); - $canView = Gate::allows('view', $content); - $canDelete = $forUser && Gate::allows('delete', $content); - $canCopy = Gate::allows('copy', $content); - - $languageName = locale_get_display_language($version->language_iso_639_3, app()->getLocale()); - $languageName = (!empty($languageName) && $languageName !== $version->language_iso_639_3) ? $languageName : null; - - return new ContentDisplayItem( - title: $version->title, - createdAt: $version->created_at?->toImmutable(), - isPublished: $version->published, + return ContentDisplayItem::fromContent( + content: $content, + version: $version, + forUser: $forUser, + showDrafts: $showDrafts, viewsCount: $item['views'] ?? 0, - contentType: $item['content_type'] ?? $version->displayed_content_type, - languageIso639_3: strtoupper($version->language_iso_639_3), - languageDisplayName: $languageName, - users: $content->users->map(fn($user) => $user->name)->join(', '), - detailsUrl: $showDrafts ? route('content.version-details', [$content, $version]) : route('content.details', [$content]), - previewUrl: route('content.preview', [$content, $version]), - useUrl: $canUse ? route('content.use', [$content, $version]) : null, - editUrl: $canEdit ? route('content.edit', [$content, $version]) : null, - shareUrl: $canView ? route('content.share', [$content, SessionScope::TOKEN_PARAM => null]) : null, - shareDialogUrl: $canView ? route('content.share-dialog', [$content]) : null, - copyUrl: $canCopy ? route('content.copy', [$content]) : null, - deleteUrl: $canDelete ? route('content.delete', [$content]) : null, - lockedByUserName: $content->getActiveLock()?->user?->name, + contentType: $item['content_type'] ?? null, ); }); } diff --git a/sourcecode/hub/composer.json b/sourcecode/hub/composer.json index 4f94238cdd..69da7c065e 100644 --- a/sourcecode/hub/composer.json +++ b/sourcecode/hub/composer.json @@ -34,6 +34,7 @@ "ekino/phpstan-banned-code": "^3.0", "fakerphp/faker": "^1.24.1", "larastan/larastan": "^3", + "laravel/boost": "^2.9", "laravel/dusk": "^8.2.12", "laravel/telescope": "^5.2.6", "mockery/mockery": "^1.6.12", diff --git a/sourcecode/hub/composer.lock b/sourcecode/hub/composer.lock index 429bd6df6e..a9d7d9e1a0 100644 --- a/sourcecode/hub/composer.lock +++ b/sourcecode/hub/composer.lock @@ -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": "a2bd9da2cf0263040423a9ab79127d62", + "content-hash": "7ca6c70c1531f5ad19a19bd10e9af108", "packages": [ { "name": "aws/aws-crt-php", @@ -8754,6 +8754,83 @@ ], "time": "2024-11-12T16:29:46+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "ekino/phpstan-banned-code", "version": "v3.0.0", @@ -9136,6 +9213,72 @@ ], "time": "2026-01-30T15:16:32+00:00" }, + { + "name": "laravel/boost", + "version": "v2.9.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0", + "reference": "2da6cbfdc6399d69b49a2dfb1e4f4eaf6bb419f0", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9|^8.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0|^1.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^1.0.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-09-17T02:40:42+00:00" + }, { "name": "laravel/dusk", "version": "v8.3.6", @@ -9210,6 +9353,142 @@ }, "time": "2026-02-10T18:14:59+00:00" }, + { + "name": "laravel/mcp", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "cfa4f38f82873eeb6848527883545f98f871e229" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/cfa4f38f82873eeb6848527883545f98f871e229", + "reference": "cfa4f38f82873eeb6848527883545f98f871e229", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-09-14T14:35:19+00:00" + }, + { + "name": "laravel/roster", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.29", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2026-07-18T17:53:15+00:00" + }, { "name": "laravel/telescope", "version": "v5.17.0", @@ -11631,6 +11910,82 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/yaml", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T00:47:26+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", diff --git a/sourcecode/hub/config/features.php b/sourcecode/hub/config/features.php index 54bc196745..d6488ea6f7 100644 --- a/sourcecode/hub/config/features.php +++ b/sourcecode/hub/config/features.php @@ -9,4 +9,8 @@ 'social-users-are-verified' => (bool) env('FEATURE_SOCIAL_USERS_ARE_VERIFIED', false), // How to display the H5P Content type: 'h5p' to use content type machine name, 'h5p_title' to use content type title 'ca-content-type-display' => env('FEATURE_CA_CONTENT_TYPE_DISPLAY', 'h5p'), + // Polling interval for content lists (e.g. '5s', 5, or null/false/0/'none'/'disabled' to disable polling) + 'list-polling-interval' => env('FEATURE_LIST_POLLING_INTERVAL', '5s'), + // Polling interval for content details (e.g. '5s', 5, or null/false/0/'none'/'disabled' to disable polling) + 'details-polling-interval' => env('FEATURE_DETAILS_POLLING_INTERVAL', '5s'), ]; diff --git a/sourcecode/hub/phpunit.dusk.xml b/sourcecode/hub/phpunit.dusk.xml new file mode 100644 index 0000000000..b58ecf00eb --- /dev/null +++ b/sourcecode/hub/phpunit.dusk.xml @@ -0,0 +1,25 @@ + + + + + ./tests/Browser + + + + + + ./app + + + + + + + + + + diff --git a/sourcecode/hub/phpunit.xml b/sourcecode/hub/phpunit.xml index 239429375f..1a1153ac37 100644 --- a/sourcecode/hub/phpunit.xml +++ b/sourcecode/hub/phpunit.xml @@ -5,9 +5,6 @@ colors="true" > - - ./tests/Browser - ./tests/Unit diff --git a/sourcecode/hub/resources/css/content/action-buttons.scss b/sourcecode/hub/resources/css/content/action-buttons.scss index a864ca34b1..e1c2af2553 100644 --- a/sourcecode/hub/resources/css/content/action-buttons.scss +++ b/sourcecode/hub/resources/css/content/action-buttons.scss @@ -9,3 +9,7 @@ content: none; } } + +.action-buttons-container { + display: contents; +} diff --git a/sourcecode/hub/resources/views/components/content/action-buttons.blade.php b/sourcecode/hub/resources/views/components/content/action-buttons.blade.php index 86c1372cb5..20e8ec619f 100644 --- a/sourcecode/hub/resources/views/components/content/action-buttons.blade.php +++ b/sourcecode/hub/resources/views/components/content/action-buttons.blade.php @@ -1,5 +1,19 @@ -@props(['content']) +@props(['content', 'oob' => false]) +
id) + id="action-buttons-{{ $content->id }}" + @endif + @if($oob && $content->id) + hx-swap-oob="outerHTML:#action-buttons-{{ $content->id }}" + @endif + @if($content->editUrl && $content->actionButtonsUrl && ($pollingInterval = \App\Configuration\Features::listPollingInterval())) + hx-get="{{ $content->actionButtonsUrl }}" + hx-trigger="every {{ $pollingInterval }}" + hx-swap="outerHTML" + @endif +> @if ($content->useUrl) @if($content->editUrl) @@ -39,3 +53,4 @@ @endif +
diff --git a/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php b/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php index 8cf4a25629..08dc6052c7 100644 --- a/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php +++ b/sourcecode/hub/resources/views/components/content/details/action-buttons.blade.php @@ -1,9 +1,34 @@ @php + use App\Configuration\Features; use App\Support\SessionScope; + use Illuminate\Support\Facades\Gate; + $activeLock = $content->getActiveLock(); + $canEdit = Gate::allows('edit', [$content, $version]); + $pollingInterval = $canEdit ? Features::detailsPollingInterval() : null; @endphp -@props(['content', 'version', 'explicitVersion']) +@props([ + 'content', + 'version', + 'explicitVersion' => false, + 'id' => 'details-action-buttons-header', + 'poll' => true, + 'oob' => false, + 'includeOob' => false, +]) +
@can('use', [$content, $version])
+ +@if ($includeOob) + +@endif diff --git a/sourcecode/hub/resources/views/components/content/result.blade.php b/sourcecode/hub/resources/views/components/content/result.blade.php index 28548f1e9f..697b2026f1 100644 --- a/sourcecode/hub/resources/views/components/content/result.blade.php +++ b/sourcecode/hub/resources/views/components/content/result.blade.php @@ -9,6 +9,18 @@ @endif + @php + $editableIds = $contents->filter(fn($item) => !empty($item->editUrl))->pluck('id')->filter()->values()->all(); + @endphp + + @if ($editableIds && ($pollingInterval = \App\Configuration\Features::listPollingInterval())) +
+ @endif +
{{ $contents->withQueryString()->links() }}
diff --git a/sourcecode/hub/resources/views/content/bulk-action-buttons.blade.php b/sourcecode/hub/resources/views/content/bulk-action-buttons.blade.php new file mode 100644 index 0000000000..fe5405028c --- /dev/null +++ b/sourcecode/hub/resources/views/content/bulk-action-buttons.blade.php @@ -0,0 +1,5 @@ +@props(['displayItems']) + +@foreach ($displayItems as $content) + +@endforeach diff --git a/sourcecode/hub/resources/views/content/details.blade.php b/sourcecode/hub/resources/views/content/details.blade.php index aea7d085fd..41b583258e 100644 --- a/sourcecode/hub/resources/views/content/details.blade.php +++ b/sourcecode/hub/resources/views/content/details.blade.php @@ -31,7 +31,7 @@
- +
@can('edit', [$content]) diff --git a/sourcecode/hub/resources/views/content/edit.blade.php b/sourcecode/hub/resources/views/content/edit.blade.php index cc2385d465..8efa8e9183 100644 --- a/sourcecode/hub/resources/views/content/edit.blade.php +++ b/sourcecode/hub/resources/views/content/edit.blade.php @@ -21,10 +21,9 @@ > diff --git a/sourcecode/hub/routes/web.php b/sourcecode/hub/routes/web.php index a80cb80829..6b7f77481c 100644 --- a/sourcecode/hub/routes/web.php +++ b/sourcecode/hub/routes/web.php @@ -64,6 +64,9 @@ ->middleware('auth') ->name('content.mine'); + Route::get('/content/bulk-action-buttons', 'bulkActionButtons') + ->name('content.bulk-action-buttons'); + Route::post('/content/toggle', 'layoutSwitch') ->name('content.layout'); @@ -205,6 +208,18 @@ ->name('content.release-lock') ->can('edit', 'content') ->whereUlid('content'); + + Route::get('/content/{content}/action-buttons') + ->uses([ContentController::class, 'actionButtons']) + ->name('content.action-buttons') + ->can('view', 'content') + ->whereUlid('content'); + + Route::get('/content/{content}/details-action-buttons') + ->uses([ContentController::class, 'detailsActionButtons']) + ->name('content.details.action-buttons') + ->can('view', 'content') + ->whereUlid('content'); }); Route::prefix('/lti/dl')->middleware([ diff --git a/sourcecode/hub/tests/Feature/ContentActionButtonsTest.php b/sourcecode/hub/tests/Feature/ContentActionButtonsTest.php new file mode 100644 index 0000000000..5a89b8a821 --- /dev/null +++ b/sourcecode/hub/tests/Feature/ContentActionButtonsTest.php @@ -0,0 +1,631 @@ +waitForMeilisearchTasks(); + } + + private function waitForMeilisearchTasks(): void + { + $client = app(MeilisearchClient::class); + $indexUid = (new Content())->searchableAs(); + $tasks = $client->getTasks( + (new TasksQuery()) + ->setIndexUids([$indexUid]) + ->setStatuses(['enqueued', 'processing']) + ); + $results = $tasks->getResults(); + if (!empty($results)) { + $uids = array_column($results, 'uid'); + $client->waitForTasks($uids); + } + } + + public function testActionButtonsEndpointReturnsPollingAttributesWhenUserCanEdit(): void + { + $owner = User::factory()->create(); + $holder = User::factory()->create(['name' => 'Lock Holder User']); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestVersion ?? $this->fail(); + + $content->acquireLock($holder); + $this->assertTrue($content->isLocked()); + + $response = $this->actingAs($owner)->get(route('content.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $response->assertOk(); + $response->assertSee('hx-trigger="every 5s"', escape: false); + $response->assertSee('hx-get="' . e(route('content.action-buttons', [ + $content, + 'version' => $version->id, + 'forUser' => 0, + 'showDrafts' => 0, + ])), escape: false); + $response->assertSee('hx-swap="outerHTML"', escape: false); + $response->assertSee('The lock is held by: Lock Holder User.'); + } + + public function testActionButtonsEndpointDoesNotReturnPollingAttributesWhenUserCannotEdit(): void + { + $owner = User::factory()->create(); + $holder = User::factory()->create(['name' => 'Lock Holder User']); + $viewer = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestPublishedVersion ?? $this->fail(); + + $content->acquireLock($holder); + $this->assertTrue($content->isLocked()); + + $response = $this->actingAs($viewer)->get(route('content.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $response->assertOk(); + $response->assertDontSee('hx-trigger="every', escape: false); + $response->assertDontSee('hx-get="' . e(route('content.action-buttons', [ + $content, + 'version' => $version->id, + ])), escape: false); + } + + public function testActionButtonsEndpointShowsLockWhenLockIsCreatedForEditor(): void + { + $owner = User::factory()->create(); + $holder = User::factory()->create(['name' => 'Lock Holder User']); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestVersion ?? $this->fail(); + + $this->assertFalse($content->isLocked()); + + // First request when unlocked: polling is active, no lock shown + $responseUnlocked = $this->actingAs($owner)->get(route('content.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $responseUnlocked->assertOk(); + $responseUnlocked->assertSee('hx-trigger="every 5s"', escape: false); + $responseUnlocked->assertDontSee('The lock is held by'); + + // Lock is acquired by another user + $content->acquireLock($holder); + $this->assertTrue($content->isLocked()); + + // Next poll returns locked status dynamically + $responseLocked = $this->actingAs($owner)->get(route('content.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $responseLocked->assertOk(); + $responseLocked->assertSee('hx-trigger="every 5s"', escape: false); + $responseLocked->assertSee('The lock is held by: Lock Holder User.'); + + // Lock is released + $content->releaseLock($holder); + $this->assertFalse($content->isLocked()); + + // Subsequent poll returns unlocked status dynamically + $responseReleased = $this->actingAs($owner)->get(route('content.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $responseReleased->assertOk(); + $responseReleased->assertSee('hx-trigger="every 5s"', escape: false); + $responseReleased->assertDontSee('The lock is held by'); + } + + public function testDetailsActionButtonsEndpointReturnsPollingAttributesWhenUserCanEdit(): void + { + $owner = User::factory()->create(); + $holder = User::factory()->create(['name' => 'Detail Lock Holder']); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestVersion ?? $this->fail(); + + $content->acquireLock($holder); + $this->assertTrue($content->isLocked()); + + $response = $this->actingAs($owner)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $response->assertOk(); + $response->assertSee('id="details-action-buttons-header"', escape: false); + $response->assertSee('hx-trigger="every 5s"', escape: false); + $response->assertSee('hx-get="' . e(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + 'explicitVersion' => 0, + ])), escape: false); + $response->assertSee('hx-swap="outerHTML"', escape: false); + $response->assertSee('id="details-action-buttons-sidebar"', escape: false); + $response->assertSee('hx-swap-oob="outerHTML:#details-action-buttons-sidebar"', escape: false); + $this->assertSame(1, substr_count((string) $response->getContent(), 'hx-trigger="every 5s"')); + } + + public function testDetailsActionButtonsEndpointDoesNotReturnPollingAttributesWhenUserCannotEdit(): void + { + $owner = User::factory()->create(); + $holder = User::factory()->create(['name' => 'Detail Lock Holder']); + $viewer = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestPublishedVersion ?? $this->fail(); + + $content->acquireLock($holder); + $this->assertTrue($content->isLocked()); + + $response = $this->actingAs($viewer)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $response->assertOk(); + $response->assertDontSee('hx-trigger="every', escape: false); + $response->assertDontSee('content/' . $content->id . '/details-action-buttons', escape: false); + } + + public function testDetailsActionButtonsEndpointShowsLockWhenLockIsCreatedForEditor(): void + { + $owner = User::factory()->create(); + $holder = User::factory()->create(['name' => 'Detail Lock Holder']); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestVersion ?? $this->fail(); + + $this->assertFalse($content->isLocked()); + + // First request when unlocked: polling is active, no lock shown + $responseUnlocked = $this->actingAs($owner)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $responseUnlocked->assertOk(); + $responseUnlocked->assertSee('hx-trigger="every 5s"', escape: false); + $responseUnlocked->assertDontSee('The lock is held by Detail Lock Holder', escape: false); + + // Lock is acquired by another user + $content->acquireLock($holder); + $this->assertTrue($content->isLocked()); + + // Next poll returns locked status dynamically for both header and sidebar OOB + $responseLocked = $this->actingAs($owner)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $responseLocked->assertOk(); + $responseLocked->assertSee('hx-trigger="every 5s"', escape: false); + $responseLocked->assertSee('disabled', escape: false); + $responseLocked->assertSee('hx-swap-oob="outerHTML:#details-action-buttons-sidebar"', escape: false); + + // Lock is released + $content->releaseLock($holder); + $this->assertFalse($content->isLocked()); + + // Subsequent poll returns unlocked status dynamically + $responseReleased = $this->actingAs($owner)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + + $responseReleased->assertOk(); + $responseReleased->assertSee('hx-trigger="every 5s"', escape: false); + $responseReleased->assertDontSee('disabled', escape: false); + } + + public function testDetailsPageRendersPollingWhenUserCanEdit(): void + { + $owner = User::factory()->create(); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + + $response = $this->actingAs($owner)->get(route('content.details', $content)); + $response->assertOk(); + $response->assertSee('hx-trigger="every 5s"', escape: false); + $response->assertSee('content/' . $content->id . '/details-action-buttons', escape: false); + $response->assertSee('id="details-action-buttons-header"', escape: false); + $response->assertSee('id="details-action-buttons-sidebar"', escape: false); + // Only one polling trigger on the details page + $this->assertSame(1, substr_count((string) $response->getContent(), 'hx-trigger="every 5s"')); + } + + public function testDetailsPageDoesNotRenderPollingWhenUserCannotEdit(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + + $response = $this->actingAs($viewer)->get(route('content.details', $content)); + $response->assertOk(); + $response->assertDontSee('hx-trigger="every', escape: false); + $response->assertDontSee('content/' . $content->id . '/details-action-buttons', escape: false); + + // Unauthenticated guest test + $guestResponse = $this->get(route('content.details', $content)); + $guestResponse->assertOk(); + $guestResponse->assertDontSee('hx-trigger="every', escape: false); + $guestResponse->assertDontSee('content/' . $content->id . '/details-action-buttons', escape: false); + } + + public function testListingRendersBulkPollingForEditableItemsOnly(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + + $content1 = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + + $content2 = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($viewer) + ->create(); + + $content1->searchable(); + $content2->searchable(); + $this->waitForMeilisearchTasks(); + + // For $owner: content1 is editable, content2 is not editable + $response = $this->actingAs($owner)->get(route('content.index')); + $response->assertOk(); + + // Both items have DOM IDs for action buttons + $response->assertSee('id="action-buttons-' . $content1->id . '"', escape: false); + $response->assertSee('id="action-buttons-' . $content2->id . '"', escape: false); + + // Bulk polling container exists with hx-swap="none" and hx-trigger="every 5s" + $response->assertSee('content/bulk-action-buttons', escape: false); + $response->assertSee('hx-trigger="every 5s"', escape: false); + $response->assertSee('hx-swap="none"', escape: false); + + // Bulk polling URL only includes editable content1 ID, not uneditable content2 ID + $response->assertSee('ids%5B0%5D=' . $content1->id, escape: false); + $response->assertDontSee('ids%5B1%5D=' . $content2->id, escape: false); + } + + public function testListingDoesNotRenderBulkPollingWhenNoItemsAreEditable(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $content->searchable(); + $this->waitForMeilisearchTasks(); + + // Viewer cannot edit $content + $response = $this->actingAs($viewer)->get(route('content.index')); + $response->assertOk(); + $response->assertDontSee('content/bulk-action-buttons', escape: false); + $response->assertDontSee('hx-trigger="every', escape: false); + + // Guest cannot edit $content + $guestResponse = $this->get(route('content.index')); + $guestResponse->assertOk(); + $guestResponse->assertDontSee('content/bulk-action-buttons', escape: false); + $guestResponse->assertDontSee('hx-trigger="every', escape: false); + } + + public function testBulkActionButtonsEndpointReturnsOobSwapsForEditableItems(): void + { + $owner = User::factory()->create(); + $holder = User::factory()->create(['name' => 'Batch Lock Holder']); + + $content1 = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + + $content2 = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + + // Initially both are unlocked + $responseUnlocked = $this->actingAs($owner)->get(route('content.bulk-action-buttons', [ + 'ids' => [$content1->id, $content2->id], + ])); + + $responseUnlocked->assertOk(); + $responseUnlocked->assertSee('id="action-buttons-' . $content1->id . '"', escape: false); + $responseUnlocked->assertSee('hx-swap-oob="outerHTML:#action-buttons-' . $content1->id . '"', escape: false); + $responseUnlocked->assertSee('id="action-buttons-' . $content2->id . '"', escape: false); + $responseUnlocked->assertSee('hx-swap-oob="outerHTML:#action-buttons-' . $content2->id . '"', escape: false); + $responseUnlocked->assertDontSee('The lock is held by'); + + // Lock content1 + $content1->acquireLock($holder); + $this->assertTrue($content1->isLocked()); + + // Batch poll returns locked state for content1 and unlocked for content2 + $responseLocked = $this->actingAs($owner)->get(route('content.bulk-action-buttons', [ + 'ids' => [$content1->id, $content2->id], + ])); + + $responseLocked->assertOk(); + $responseLocked->assertSee('The lock is held by: Batch Lock Holder.'); + + // Lock is released + $content1->releaseLock($holder); + $this->assertFalse($content1->isLocked()); + + // Batch poll returns unlocked state again + $responseReleased = $this->actingAs($owner)->get(route('content.bulk-action-buttons', [ + 'ids' => [$content1->id, $content2->id], + ])); + + $responseReleased->assertOk(); + $responseReleased->assertDontSee('The lock is held by'); + } + + public function testBulkActionButtonsEndpointDoesNotReturnItemsUserCannotEdit(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + + $response = $this->actingAs($viewer)->get(route('content.bulk-action-buttons', [ + 'ids' => [$content->id], + ])); + + $response->assertOk(); + $this->assertEmpty(trim((string) $response->getContent())); + } + + public function testBulkActionButtonsWithEmptyIdsReturnsEmptyContent(): void + { + $viewer = User::factory()->create(); + + $response = $this->actingAs($viewer)->get(route('content.bulk-action-buttons')); + $response->assertOk(); + $this->assertEmpty(trim((string) $response->getContent())); + } + + public function testListingRendersCustomConfiguredPollingIntervalForEditor(): void + { + config(['features.list-polling-interval' => '10s']); + + $owner = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $content->searchable(); + $this->waitForMeilisearchTasks(); + + $response = $this->actingAs($owner)->get(route('content.index')); + $response->assertOk(); + $response->assertSee('hx-trigger="every 10s"', escape: false); + } + + public function testListingRendersNumericConfiguredPollingIntervalForEditor(): void + { + config(['features.list-polling-interval' => 15]); + + $owner = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $content->searchable(); + $this->waitForMeilisearchTasks(); + + $response = $this->actingAs($owner)->get(route('content.index')); + $response->assertOk(); + $response->assertSee('hx-trigger="every 15s"', escape: false); + } + + public function testListingRendersNoPollingWhenDisabled(): void + { + config(['features.list-polling-interval' => null]); + + $owner = User::factory()->create(); + + $content = Content::factory() + ->shared() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $content->searchable(); + $this->waitForMeilisearchTasks(); + + $response = $this->actingAs($owner)->get(route('content.index')); + $response->assertOk(); + $response->assertDontSee('content/bulk-action-buttons', escape: false); + $response->assertDontSee('hx-trigger="every', escape: false); + + // Also test with 'disabled' + config(['features.list-polling-interval' => 'disabled']); + $responseDisabled = $this->actingAs($owner)->get(route('content.index')); + $responseDisabled->assertOk(); + $responseDisabled->assertDontSee('content/bulk-action-buttons', escape: false); + $responseDisabled->assertDontSee('hx-trigger="every', escape: false); + + // Also test with 0 + config(['features.list-polling-interval' => 0]); + $responseZero = $this->actingAs($owner)->get(route('content.index')); + $responseZero->assertOk(); + $responseZero->assertDontSee('content/bulk-action-buttons', escape: false); + $responseZero->assertDontSee('hx-trigger="every', escape: false); + + // Also test with false + config(['features.list-polling-interval' => false]); + $responseFalse = $this->actingAs($owner)->get(route('content.index')); + $responseFalse->assertOk(); + $responseFalse->assertDontSee('content/bulk-action-buttons', escape: false); + $responseFalse->assertDontSee('hx-trigger="every', escape: false); + } + + public function testDetailsRendersCustomConfiguredPollingInterval(): void + { + config(['features.details-polling-interval' => '10s']); + + $owner = User::factory()->create(); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestVersion ?? $this->fail(); + + $response = $this->actingAs($owner)->get(route('content.details', $content)); + $response->assertOk(); + $response->assertSee('hx-trigger="every 10s"', escape: false); + $response->assertSee('details-action-buttons', escape: false); + + // Also check the action buttons endpoint directly + $endpointResponse = $this->actingAs($owner)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + $endpointResponse->assertOk(); + $endpointResponse->assertSee('hx-trigger="every 10s"', escape: false); + } + + public function testDetailsRendersNumericConfiguredPollingInterval(): void + { + config(['features.details-polling-interval' => 15]); + + $owner = User::factory()->create(); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestVersion ?? $this->fail(); + + $response = $this->actingAs($owner)->get(route('content.details', $content)); + $response->assertOk(); + $response->assertSee('hx-trigger="every 15s"', escape: false); + + $endpointResponse = $this->actingAs($owner)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + $endpointResponse->assertOk(); + $endpointResponse->assertSee('hx-trigger="every 15s"', escape: false); + } + + public function testDetailsRendersNoPollingWhenDisabled(): void + { + config(['features.details-polling-interval' => null]); + + $owner = User::factory()->create(); + + $content = Content::factory() + ->withVersion(ContentVersion::factory(['published' => true])) + ->withUser($owner) + ->create(); + $version = $content->latestVersion ?? $this->fail(); + + $response = $this->actingAs($owner)->get(route('content.details', $content)); + $response->assertOk(); + $response->assertDontSee('hx-trigger="every', escape: false); + + $endpointResponse = $this->actingAs($owner)->get(route('content.details.action-buttons', [ + $content, + 'version' => $version->id, + ])); + $endpointResponse->assertOk(); + $endpointResponse->assertDontSee('hx-trigger="every', escape: false); + + // Also test with 'disabled' + config(['features.details-polling-interval' => 'disabled']); + $responseDisabled = $this->actingAs($owner)->get(route('content.details', $content)); + $responseDisabled->assertOk(); + $responseDisabled->assertDontSee('hx-trigger="every', escape: false); + + // Also test with 0 + config(['features.details-polling-interval' => 0]); + $responseZero = $this->actingAs($owner)->get(route('content.details', $content)); + $responseZero->assertOk(); + $responseZero->assertDontSee('hx-trigger="every', escape: false); + + // Also test with false + config(['features.details-polling-interval' => false]); + $responseFalse = $this->actingAs($owner)->get(route('content.details', $content)); + $responseFalse->assertOk(); + $responseFalse->assertDontSee('hx-trigger="every', escape: false); + } +} diff --git a/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php b/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php index 481fb045ad..91966b6b49 100644 --- a/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php +++ b/sourcecode/hub/tests/Feature/ContentAuthor/LeafVersionsTest.php @@ -7,6 +7,7 @@ use App\Models\Content; use App\Models\ContentVersion; use App\Models\LtiTool; +use App\Models\User; use Cerpus\EdlibResourceKit\Oauth1\Credentials; use Cerpus\EdlibResourceKit\Oauth1\Request as Oauth1Request; use Cerpus\EdlibResourceKit\Oauth1\SignerInterface; @@ -193,4 +194,42 @@ public function testRejectsInvalidCredentials(): void $this->post($url, $oauthRequest->toArray()) ->assertUnauthorized(); } + + public function testUpdateReleasesContentLock(): void + { + $user = User::factory()->create(); + $content = Content::factory() + ->withVersion( + ContentVersion::factory() + ->for($this->tool, 'tool') + ->withLaunchUrl('https://ca.test/h5p/1'), + ) + ->withUser($user) + ->create(); + $version = $content->latestVersion; + $this->assertNotNull($version); + + $content->acquireLock($user); + $this->assertTrue($content->isLocked()); + + $url = route('author.content.update', [$this->tool, $content, $version]); + $contentItems = json_encode([ + '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', + '@graph' => [ + [ + '@type' => 'LtiLinkItem', + 'url' => 'https://ca.test/h5p/1', + 'title' => 'Updated Title', + 'mediaType' => 'application/vnd.ims.lti.v1.ltilink', + ], + ], + ], JSON_THROW_ON_ERROR); + + $this->signedPost($url, [ + 'content_items' => $contentItems, + 'user_id' => (string) $user->id, + ])->assertCreated(); + + $this->assertFalse($content->refresh()->isLocked()); + } } diff --git a/sourcecode/hub/tests/Feature/ContentTest.php b/sourcecode/hub/tests/Feature/ContentTest.php index 46bdd9a718..813cf0d9ae 100644 --- a/sourcecode/hub/tests/Feature/ContentTest.php +++ b/sourcecode/hub/tests/Feature/ContentTest.php @@ -13,6 +13,7 @@ use App\Models\ContentView; use App\Models\ContentViewsAccumulated; use App\Models\LtiPlatform; +use App\Models\LtiTool; use App\Models\User; use Carbon\Carbon; use Cerpus\EdlibResourceKit\Oauth1\Request; @@ -353,4 +354,49 @@ public function testRefreshesLock(): void $this->assertSame('2025-01-01T00:00:00Z', $lock->created_at?->toIso8601ZuluString()); $this->assertSame('2025-01-01T00:00:30Z', $lock->updated_at?->toIso8601ZuluString()); } + + public function testLtiUpdateReleasesLock(): void + { + $user = User::factory()->create(); + $tool = LtiTool::factory()->create([ + 'consumer_key' => 'test-key', + 'consumer_secret' => 'test-secret', + ]); + $content = Content::factory() + ->withVersion(ContentVersion::factory()->for($tool, 'tool')) + ->withUser($user) + ->create(); + $version = $content->latestVersion; + $this->assertNotNull($version); + + $content->acquireLock($user); + $this->assertTrue($content->isLocked()); + + $url = route('content.lti-update', [$tool, $content, $version]); + $contentItems = json_encode([ + '@context' => 'http://purl.imsglobal.org/ctx/lti/v1/ContentItem', + '@graph' => [ + [ + '@type' => 'LtiLinkItem', + '@id' => 'https://example.com/h5p/1', + 'url' => 'https://example.com/h5p/1', + 'title' => 'Updated Title', + 'mediaType' => 'application/vnd.ims.lti.v1.ltilink', + ], + ], + ], JSON_THROW_ON_ERROR); + + $request = new Request('POST', $url, [ + 'content_items' => $contentItems, + 'lti_message_type' => 'ContentItemSelection', + ]); + $signedRequest = $this->app->make(Signer::class)->sign($request, $tool->getOauth1Credentials()); + + $this->actingAs($user) + ->withCookie('_edlib_cookies', '1') + ->post($url, $signedRequest->toArray()) + ->assertOk(); + + $this->assertFalse($content->refresh()->isLocked()); + } } diff --git a/sourcecode/hub/tests/Unit/Config/FeaturesTest.php b/sourcecode/hub/tests/Unit/Config/FeaturesTest.php new file mode 100644 index 0000000000..5164aa1e7f --- /dev/null +++ b/sourcecode/hub/tests/Unit/Config/FeaturesTest.php @@ -0,0 +1,37 @@ +assertSame($expected, Features::formatPollingInterval($input)); + } +}