From 4871c6032e46de28deff7997d00ef9653cc9d47f Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 18 Mar 2026 22:20:20 +0100 Subject: [PATCH] feat: extract bookmarks as standalone module with MintyPHP\Module\Bookmarks namespace Moves bookmarks from core hardcoding into modules/bookmarks/ as a fully self-contained module, following the same pattern as addressbook. Module contributions via platform slots: - aside.tab_panel: bookmark sidebar panel - topbar.right_item: bookmark toggle button - layout.body_end_template: bookmark/group dialogs - layout.head_style: bookmark CSS (form + sidebar) - runtime.component: bookmark-save and bookmark-panel (phase: late) - search.resource_item: bookmarks in global search (user-scoped via {{userId}}) Backend fully in module namespace (MintyPHP\Module\Bookmarks\*): - Service: BookmarkService, BookmarkServicesFactory, BookmarkRepositoryFactory - Repository: BookmarkRepository, BookmarkGroupRepository, BookmarkNavigationRepository - Support: BookmarkUrlNormalizer - Providers: BookmarksSessionProvider, BookmarksLayoutProvider, BookmarksSearchProvider Core cleanup: - Removed all bookmark-specific markup from core templates - Removed core DI registrations for bookmark services - Removed core bookmark pages, JS, CSS - AuthSessionTenantContextService delegates to module SessionProvider Architecture guards: - NoBookmarksHardcodingTest: verifies zero bookmark references in core - testModuleClassesUseModuleNamespace: prevents core namespace leakage Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BookmarksAuthorizationPolicy.php | 26 ++ .../Bookmarks/BookmarksContainerRegistrar.php | 21 ++ .../Providers/BookmarksLayoutProvider.php | 29 +++ .../Providers/BookmarksSearchProvider.php | 53 ++++ .../Providers/BookmarksSessionProvider.php | 35 +++ .../Repository}/BookmarkGroupRepository.php | 2 +- .../BookmarkGroupRepositoryInterface.php | 2 +- .../BookmarkNavigationRepository.php | 8 +- .../BookmarkNavigationRepositoryInterface.php | 2 +- .../Repository}/BookmarkRepository.php | 100 +++----- .../BookmarkRepositoryInterface.php | 2 +- .../Service/BookmarkRepositoryFactory.php | 29 +-- .../Bookmarks/Service}/BookmarkService.php | 81 ++++-- .../Service/BookmarkServicesFactory.php | 22 ++ .../Support/BookmarkUrlNormalizer.php | 2 +- modules/bookmarks/module.php | 136 ++++++++++ .../pages}/bookmarks/delete-data().php | 20 +- .../pages}/bookmarks/group-delete-data().php | 10 +- .../pages}/bookmarks/group-save-data().php | 12 +- .../pages}/bookmarks/reorder-data().php | 80 +++--- .../pages}/bookmarks/save-data().php | 12 +- .../pages}/bookmarks/update-data().php | 4 +- .../templates/aside-bookmarks-panel.phtml | 242 ++++++++++++++++++ .../templates/bookmark-dialogs.phtml | 22 +- .../templates/topbar-bookmark-button.phtml | 46 ++++ .../Service}/BookmarkServiceTest.php | 10 +- .../Support/BookmarkUrlNormalizerTest.php | 88 +++++++ .../web}/css/components/app-bookmark-form.css | 9 - .../web/css/layout/app-sidebar-bookmarks.css | 185 +++++++++++++ .../web}/js/components/app-bookmark-panel.js | 49 +++- .../web}/js/components/app-bookmark-save.js | 51 ++-- .../FrontendComponentRuntimeContractTest.php | 227 ++++++++++++++++ .../NoBookmarksHardcodingTest.php | 84 ++++++ tests/Support/BookmarkUrlNormalizerTest.php | 51 ---- .../BookmarksAuthorizationPolicyTest.php | 45 ++++ web/js/components/app-bookmark-init.js | 10 - web/modules/bookmarks | 1 + 37 files changed, 1514 insertions(+), 294 deletions(-) create mode 100644 modules/bookmarks/lib/Module/Bookmarks/BookmarksAuthorizationPolicy.php create mode 100644 modules/bookmarks/lib/Module/Bookmarks/BookmarksContainerRegistrar.php create mode 100644 modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksLayoutProvider.php create mode 100644 modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSearchProvider.php create mode 100644 modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSessionProvider.php rename {lib/Repository/User => modules/bookmarks/lib/Module/Bookmarks/Repository}/BookmarkGroupRepository.php (99%) rename {lib/Repository/User => modules/bookmarks/lib/Module/Bookmarks/Repository}/BookmarkGroupRepositoryInterface.php (93%) rename {lib/Repository/User => modules/bookmarks/lib/Module/Bookmarks/Repository}/BookmarkNavigationRepository.php (95%) rename {lib/Repository/User => modules/bookmarks/lib/Module/Bookmarks/Repository}/BookmarkNavigationRepositoryInterface.php (86%) rename {lib/Repository/User => modules/bookmarks/lib/Module/Bookmarks/Repository}/BookmarkRepository.php (76%) rename {lib/Repository/User => modules/bookmarks/lib/Module/Bookmarks/Repository}/BookmarkRepositoryInterface.php (95%) rename lib/Service/Bookmark/BookmarkServicesFactory.php => modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkRepositoryFactory.php (51%) rename {lib/Service/Bookmark => modules/bookmarks/lib/Module/Bookmarks/Service}/BookmarkService.php (86%) create mode 100644 modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkServicesFactory.php rename {lib => modules/bookmarks/lib/Module/Bookmarks}/Support/BookmarkUrlNormalizer.php (98%) create mode 100644 modules/bookmarks/module.php rename {pages => modules/bookmarks/pages}/bookmarks/delete-data().php (55%) rename {pages => modules/bookmarks/pages}/bookmarks/group-delete-data().php (76%) rename {pages => modules/bookmarks/pages}/bookmarks/group-save-data().php (73%) rename {pages => modules/bookmarks/pages}/bookmarks/reorder-data().php (55%) rename {pages => modules/bookmarks/pages}/bookmarks/save-data().php (73%) rename {pages => modules/bookmarks/pages}/bookmarks/update-data().php (88%) create mode 100644 modules/bookmarks/templates/aside-bookmarks-panel.phtml rename templates/partials/app-bookmark-dialog.phtml => modules/bookmarks/templates/bookmark-dialogs.phtml (80%) create mode 100644 modules/bookmarks/templates/topbar-bookmark-button.phtml rename {tests/Service/Bookmark => modules/bookmarks/tests/Module/Bookmarks/Service}/BookmarkServiceTest.php (98%) create mode 100644 modules/bookmarks/tests/Module/Bookmarks/Support/BookmarkUrlNormalizerTest.php rename {web => modules/bookmarks/web}/css/components/app-bookmark-form.css (92%) create mode 100644 modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css rename {web => modules/bookmarks/web}/js/components/app-bookmark-panel.js (91%) rename {web => modules/bookmarks/web}/js/components/app-bookmark-save.js (92%) create mode 100644 tests/Architecture/FrontendComponentRuntimeContractTest.php create mode 100644 tests/Architecture/NoBookmarksHardcodingTest.php delete mode 100644 tests/Support/BookmarkUrlNormalizerTest.php create mode 100644 tests/Unit/Module/BookmarksAuthorizationPolicyTest.php delete mode 100644 web/js/components/app-bookmark-init.js create mode 120000 web/modules/bookmarks diff --git a/modules/bookmarks/lib/Module/Bookmarks/BookmarksAuthorizationPolicy.php b/modules/bookmarks/lib/Module/Bookmarks/BookmarksAuthorizationPolicy.php new file mode 100644 index 0000000..533576c --- /dev/null +++ b/modules/bookmarks/lib/Module/Bookmarks/BookmarksAuthorizationPolicy.php @@ -0,0 +1,26 @@ +set(BookmarkRepositoryFactory::class, static fn (): BookmarkRepositoryFactory => new BookmarkRepositoryFactory()); + $container->set(BookmarkServicesFactory::class, static fn (AppContainer $c): BookmarkServicesFactory => new BookmarkServicesFactory( + $c->get(BookmarkRepositoryFactory::class) + )); + $container->set(BookmarkService::class, static fn (AppContainer $c): BookmarkService => $c->get(BookmarkServicesFactory::class)->createBookmarkService()); + } +} diff --git a/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksLayoutProvider.php b/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksLayoutProvider.php new file mode 100644 index 0000000..e41819a --- /dev/null +++ b/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksLayoutProvider.php @@ -0,0 +1,29 @@ + [], 'ungrouped' => []]; + + $currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri( + (string) ($_SERVER['REQUEST_URI'] ?? ''), + localeBase() + ); + + return [ + 'bookmarks.nav' => [ + 'grouped' => $grouped, + 'current_path' => $currentPath, + ], + ]; + } +} diff --git a/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSearchProvider.php b/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSearchProvider.php new file mode 100644 index 0000000..7c6cb9a --- /dev/null +++ b/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSearchProvider.php @@ -0,0 +1,53 @@ + [ + 'label' => t('Bookmarks'), + 'permission' => '', + 'count_sql' => "SELECT COUNT(*) FROM user_bookmarks WHERE user_id = {{userId}} AND (name LIKE ? ESCAPE '\\\\' OR url LIKE ? ESCAPE '\\\\')", + 'preview_sql' => "SELECT id, name, url FROM user_bookmarks WHERE user_id = {{userId}} AND (name LIKE ? ESCAPE '\\\\' OR url LIKE ? ESCAPE '\\\\') ORDER BY sort_order ASC LIMIT ?", + 'result_sql' => "SELECT id, name, url FROM user_bookmarks WHERE user_id = {{userId}} AND (name LIKE ? ESCAPE '\\\\' OR url LIKE ? ESCAPE '\\\\') ORDER BY sort_order ASC", + ], + ]; + } + + public function mapResultItem(string $resourceKey, array $row): ?array + { + if ($resourceKey !== 'bookmarks') { + return null; + } + + $name = trim((string) ($row['name'] ?? '')); + $url = trim((string) ($row['url'] ?? '')); + + if ($name === '') { + return null; + } + + return [ + 'title' => $name, + 'subtitle' => $url, + 'url' => $url !== '' ? $url : '#', + 'icon' => 'bi-bookmark-star', + ]; + } + + public function listUrl(string $resourceKey, string $encodedSearch): string + { + return ''; + } +} diff --git a/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSessionProvider.php b/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSessionProvider.php new file mode 100644 index 0000000..bcbea96 --- /dev/null +++ b/modules/bookmarks/lib/Module/Bookmarks/Providers/BookmarksSessionProvider.php @@ -0,0 +1,35 @@ +get(SessionStoreInterface::class); + $bookmarkService = $container->get(BookmarkService::class); + if (!$sessionStore instanceof SessionStoreInterface || !$bookmarkService instanceof BookmarkService) { + return; + } + + $sessionStore->set(self::SESSION_KEY, $bookmarkService->listGroupedForUser($userId)); + } + + public function clear(): void + { + unset($_SESSION[self::SESSION_KEY]); + } +} diff --git a/lib/Repository/User/BookmarkGroupRepository.php b/modules/bookmarks/lib/Module/Bookmarks/Repository/BookmarkGroupRepository.php similarity index 99% rename from lib/Repository/User/BookmarkGroupRepository.php rename to modules/bookmarks/lib/Module/Bookmarks/Repository/BookmarkGroupRepository.php index ed6fd5c..b8909bb 100644 --- a/lib/Repository/User/BookmarkGroupRepository.php +++ b/modules/bookmarks/lib/Module/Bookmarks/Repository/BookmarkGroupRepository.php @@ -1,6 +1,6 @@ unwrapList($rows); } @@ -249,60 +244,31 @@ class BookmarkRepository implements BookmarkRepositoryInterface } } - public function clearGroupId(int $groupId, int $userId): bool + /** @return list> */ + public function listByGroup(int $userId, int $groupId): array { - if ($groupId <= 0 || $userId <= 0) { - return false; - } - - $db = DB::handle(); - try { - $db->begin_transaction(); - - $baseSort = DB::selectValue( - 'select GREATEST( - COALESCE((select max(sort_order) from user_bookmarks where user_id = ? and group_id is NULL), 0), - COALESCE((select max(sort_order) from user_bookmark_groups where user_id = ?), 0) - )', - (string) $userId, - (string) $userId - ); - $nextSort = (int) $baseSort; - - $rows = DB::select( - 'select id from user_bookmarks where user_id = ? and group_id = ? order by sort_order, id', - (string) $userId, - (string) $groupId - ); - $bookmarks = $this->unwrapList($rows); - - foreach ($bookmarks as $bookmark) { - $bookmarkId = (int) ($bookmark['id'] ?? 0); - if ($bookmarkId <= 0) { - continue; - } - $nextSort++; - $updated = DB::update( - 'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?', - (string) $nextSort, - (string) $bookmarkId, - (string) $userId - ); - if ($updated === false) { - $db->rollback(); - return false; - } - } - - $db->commit(); - return true; - } catch (\Throwable) { - try { - $db->rollback(); - } catch (\Throwable) { - // no-op - } + if ($userId <= 0 || $groupId <= 0) { + return []; + } + $rows = DB::select( + 'select id, user_id, group_id, name, url, sort_order from user_bookmarks where user_id = ? and group_id = ? order by sort_order, id', + (string) $userId, + (string) $groupId + ); + return $this->unwrapList($rows); + } + + public function unsetGroupId(int $bookmarkId, int $userId, int $sortOrder): bool + { + if ($bookmarkId <= 0 || $userId <= 0 || $sortOrder <= 0) { return false; } + $updated = DB::update( + 'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?', + (string) $sortOrder, + (string) $bookmarkId, + (string) $userId + ); + return $updated !== false; } } diff --git a/lib/Repository/User/BookmarkRepositoryInterface.php b/modules/bookmarks/lib/Module/Bookmarks/Repository/BookmarkRepositoryInterface.php similarity index 95% rename from lib/Repository/User/BookmarkRepositoryInterface.php rename to modules/bookmarks/lib/Module/Bookmarks/Repository/BookmarkRepositoryInterface.php index 0bba73b..1f3d7d0 100644 --- a/lib/Repository/User/BookmarkRepositoryInterface.php +++ b/modules/bookmarks/lib/Module/Bookmarks/Repository/BookmarkRepositoryInterface.php @@ -1,6 +1,6 @@ bookmarkService ??= new BookmarkService( - $this->createBookmarkRepository(), - $this->createBookmarkGroupRepository(), - $this->createBookmarkNavigationRepository() - ); - } - - public function createBookmarkRepository(): BookmarkRepository + public function createBookmarkRepository(): BookmarkRepositoryInterface { return $this->bookmarkRepository ??= new BookmarkRepository(); } - public function createBookmarkGroupRepository(): BookmarkGroupRepository + public function createBookmarkGroupRepository(): BookmarkGroupRepositoryInterface { return $this->groupRepository ??= new BookmarkGroupRepository(); } - public function createBookmarkNavigationRepository(): BookmarkNavigationRepository + public function createBookmarkNavigationRepository(): BookmarkNavigationRepositoryInterface { return $this->navigationRepository ??= new BookmarkNavigationRepository(); } diff --git a/lib/Service/Bookmark/BookmarkService.php b/modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkService.php similarity index 86% rename from lib/Service/Bookmark/BookmarkService.php rename to modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkService.php index 9903e1c..f27efc9 100644 --- a/lib/Service/Bookmark/BookmarkService.php +++ b/modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkService.php @@ -1,11 +1,11 @@ false, 'error' => 'url_required']; } - $existing = $this->findExistingByCanonicalUrl($userId, $url); + $existing = $this->bookmarkRepository->findByUserAndUrl($userId, $url); if ($groupId !== null && $groupId > 0) { $group = $this->groupRepository->findById($groupId, $userId); @@ -315,9 +315,20 @@ class BookmarkService public function deleteGroup(int $userId, int $groupId): bool { - if (!$this->bookmarkRepository->clearGroupId($groupId, $userId)) { - return false; + $bookmarks = $this->bookmarkRepository->listByGroup($userId, $groupId); + $nextSort = $this->navigationRepository->nextRootSortOrder($userId); + + foreach ($bookmarks as $bookmark) { + $bookmarkId = (int) ($bookmark['id'] ?? 0); + if ($bookmarkId <= 0) { + continue; + } + if (!$this->bookmarkRepository->unsetGroupId($bookmarkId, $userId, $nextSort)) { + return false; + } + $nextSort++; } + return $this->groupRepository->delete($groupId, $userId); } @@ -333,6 +344,42 @@ class BookmarkService return $this->navigationRepository->updateRootSortOrders($userId, $rootOrderPairs); } + /** + * @param list $items + * @return array{ok: bool, error?: string} + */ + public function reorderFromItems(int $userId, string $type, array $items): array + { + $allowedTypes = ['groups', 'bookmarks']; + if (!in_array($type, $allowedTypes, true)) { + return ['ok' => false, 'error' => 'invalid_type']; + } + + if ($items === []) { + return ['ok' => false, 'error' => 'invalid_items']; + } + + $ok = $type === 'groups' + ? $this->reorderGroups($userId, $items) + : $this->reorderBookmarks($userId, $items); + + return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'reorder_failed']; + } + + /** + * @param list $rootPairs + * @return array{ok: bool, error?: string} + */ + public function reorderRootFromItems(int $userId, array $rootPairs): array + { + if ($rootPairs === []) { + return ['ok' => false, 'error' => 'invalid_items']; + } + + $ok = $this->reorderRoot($userId, $rootPairs); + return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'reorder_failed']; + } + private function sanitizeUrl(string $url): string { $url = BookmarkUrlNormalizer::canonicalizeRelative($url); @@ -364,22 +411,4 @@ class BookmarkService return $value; } - /** @return array|false */ - private function findExistingByCanonicalUrl(int $userId, string $canonicalUrl): array|false - { - $existing = $this->bookmarkRepository->findByUserAndUrl($userId, $canonicalUrl); - if ($existing) { - return $existing; - } - - $rows = $this->bookmarkRepository->listByUser($userId); - foreach ($rows as $row) { - $rowUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($row['url'] ?? '')); - if ($rowUrl === $canonicalUrl) { - return $row; - } - } - - return false; - } } diff --git a/modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkServicesFactory.php b/modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkServicesFactory.php new file mode 100644 index 0000000..2e93709 --- /dev/null +++ b/modules/bookmarks/lib/Module/Bookmarks/Service/BookmarkServicesFactory.php @@ -0,0 +1,22 @@ +bookmarkService ??= new BookmarkService( + $this->bookmarkRepositoryFactory->createBookmarkRepository(), + $this->bookmarkRepositoryFactory->createBookmarkGroupRepository(), + $this->bookmarkRepositoryFactory->createBookmarkNavigationRepository() + ); + } +} diff --git a/lib/Support/BookmarkUrlNormalizer.php b/modules/bookmarks/lib/Module/Bookmarks/Support/BookmarkUrlNormalizer.php similarity index 98% rename from lib/Support/BookmarkUrlNormalizer.php rename to modules/bookmarks/lib/Module/Bookmarks/Support/BookmarkUrlNormalizer.php index 4c5d500..eac1602 100644 --- a/lib/Support/BookmarkUrlNormalizer.php +++ b/modules/bookmarks/lib/Module/Bookmarks/Support/BookmarkUrlNormalizer.php @@ -1,6 +1,6 @@ 'bookmarks', + 'version' => '1.0.0', + 'enabled_by_default' => true, + 'load_order' => 20, + + 'routes' => [ + ['path' => 'bookmarks/save-data', 'target' => 'bookmarks/save-data'], + ['path' => 'bookmarks/update-data', 'target' => 'bookmarks/update-data'], + ['path' => 'bookmarks/delete-data', 'target' => 'bookmarks/delete-data'], + ['path' => 'bookmarks/group-save-data', 'target' => 'bookmarks/group-save-data'], + ['path' => 'bookmarks/group-delete-data', 'target' => 'bookmarks/group-delete-data'], + ['path' => 'bookmarks/reorder-data', 'target' => 'bookmarks/reorder-data'], + ], + 'public_paths' => [], + + 'container_registrars' => [ + \MintyPHP\Module\Bookmarks\BookmarksContainerRegistrar::class, + ], + + 'ui_slots' => [ + 'aside.tab_panel' => [ + [ + 'key' => 'bookmarks', + 'label' => 'Bookmarks', + 'icon' => 'bi-bookmark-star', + 'href' => '', + 'permission' => 'can_use_bookmarks', + 'panel_template' => __DIR__ . '/templates/aside-bookmarks-panel.phtml', + 'details_storage' => 'aside-bookmarks-groups-v1', + 'order' => 120, + ], + ], + 'topbar.right_item' => [ + [ + 'key' => 'bookmarks-toggle', + 'template' => __DIR__ . '/templates/topbar-bookmark-button.phtml', + 'permission' => 'can_use_bookmarks', + 'order' => 120, + ], + ], + 'layout.body_end_template' => [ + [ + 'key' => 'bookmarks-dialogs', + 'template' => __DIR__ . '/templates/bookmark-dialogs.phtml', + 'permission' => 'can_use_bookmarks', + 'order' => 120, + ], + ], + 'layout.head_style' => [ + [ + 'key' => 'bookmarks-form-style', + 'path' => 'modules/bookmarks/css/components/app-bookmark-form.css', + 'permission' => 'can_use_bookmarks', + 'order' => 120, + ], + [ + 'key' => 'bookmarks-aside-style', + 'path' => 'modules/bookmarks/css/layout/app-sidebar-bookmarks.css', + 'permission' => 'can_use_bookmarks', + 'order' => 121, + ], + ], + 'search.resource_item' => [ + [ + 'key' => 'bookmarks', + 'label' => 'Bookmarks', + 'base_url' => '#', + 'permission' => 'can_use_bookmarks', + 'order' => 130, + ], + ], + 'runtime.component' => [ + [ + 'key' => 'bookmark-save', + 'script' => 'modules/bookmarks/js/components/app-bookmark-save.js', + 'export' => 'initBookmarkSave', + 'scope' => 'global', + 'config_path' => 'components.bookmarkSave', + 'default_config' => [ + 'openButtonSelector' => '[data-bookmark-open-dialog]', + 'dialogSelector' => '[data-app-bookmark-dialog]', + 'groupDialogSelector' => '[data-app-bookmark-group-dialog]', + ], + 'phase' => 'late', + 'permission' => 'can_use_bookmarks', + 'order' => 120, + ], + [ + 'key' => 'bookmark-panel', + 'script' => 'modules/bookmarks/js/components/app-bookmark-panel.js', + 'export' => 'initBookmarkPanel', + 'selector' => '[data-app-bookmark-panel]', + 'config_path' => 'components.bookmarkPanel', + 'default_config' => [ + 'selector' => '[data-app-bookmark-panel]', + ], + 'phase' => 'late', + 'permission' => 'can_use_bookmarks', + 'order' => 121, + ], + ], + ], + + 'authorization_policies' => [ + \MintyPHP\Module\Bookmarks\BookmarksAuthorizationPolicy::class, + ], + + 'layout_capabilities' => [ + 'can_use_bookmarks' => 'bookmarks.use', + ], + + 'search_resources' => [ + \MintyPHP\Module\Bookmarks\Providers\BookmarksSearchProvider::class, + ], + 'asset_groups' => [], + 'scheduler_jobs' => [], + + 'layout_context_providers' => [ + \MintyPHP\Module\Bookmarks\Providers\BookmarksLayoutProvider::class, + ], + 'session_providers' => [ + \MintyPHP\Module\Bookmarks\Providers\BookmarksSessionProvider::class, + ], + + 'permissions' => [], + 'migrations_path' => null, +]; diff --git a/pages/bookmarks/delete-data().php b/modules/bookmarks/pages/bookmarks/delete-data().php similarity index 55% rename from pages/bookmarks/delete-data().php rename to modules/bookmarks/pages/bookmarks/delete-data().php index 10f43d0..3305354 100644 --- a/pages/bookmarks/delete-data().php +++ b/modules/bookmarks/pages/bookmarks/delete-data().php @@ -2,7 +2,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; -use MintyPHP\Service\Bookmark\BookmarkService; +use MintyPHP\Module\Bookmarks\Service\BookmarkService; use MintyPHP\Session; use MintyPHP\Support\Guard; @@ -22,13 +22,27 @@ if (!Session::checkCsrfToken()) { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + $bookmarkId = (int) requestInput()->body('id'); +if ($bookmarkId <= 0) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_bookmark_id']); + return; +} $service = app(BookmarkService::class); $deleted = $service->deleteBookmark($userId, $bookmarkId); if ($deleted) { - app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); + app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId)); + Router::json(['ok' => true]); + return; } -Router::json(['ok' => $deleted]); +http_response_code(404); +Router::json(['ok' => false, 'error' => 'not_found']); diff --git a/pages/bookmarks/group-delete-data().php b/modules/bookmarks/pages/bookmarks/group-delete-data().php similarity index 76% rename from pages/bookmarks/group-delete-data().php rename to modules/bookmarks/pages/bookmarks/group-delete-data().php index ffec619..11e2889 100644 --- a/pages/bookmarks/group-delete-data().php +++ b/modules/bookmarks/pages/bookmarks/group-delete-data().php @@ -2,7 +2,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; -use MintyPHP\Service\Bookmark\BookmarkService; +use MintyPHP\Module\Bookmarks\Service\BookmarkService; use MintyPHP\Session; use MintyPHP\Support\Guard; @@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + $groupId = (int) requestInput()->body('id'); if ($groupId <= 0) { http_response_code(400); @@ -33,7 +39,7 @@ $service = app(BookmarkService::class); $deleted = $service->deleteGroup($userId, $groupId); if ($deleted) { - app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); + app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId)); Router::json(['ok' => true]); return; } diff --git a/pages/bookmarks/group-save-data().php b/modules/bookmarks/pages/bookmarks/group-save-data().php similarity index 73% rename from pages/bookmarks/group-save-data().php rename to modules/bookmarks/pages/bookmarks/group-save-data().php index bcfc740..2cdc7bc 100644 --- a/pages/bookmarks/group-save-data().php +++ b/modules/bookmarks/pages/bookmarks/group-save-data().php @@ -2,7 +2,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; -use MintyPHP\Service\Bookmark\BookmarkService; +use MintyPHP\Module\Bookmarks\Service\BookmarkService; use MintyPHP\Session; use MintyPHP\Support\Guard; @@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + $name = trim((string) requestInput()->body('name')); $icon = trim((string) requestInput()->body('icon')); $groupId = requestInput()->body('id'); @@ -31,7 +37,9 @@ $service = app(BookmarkService::class); $result = $service->saveGroup($userId, $name, $icon, $groupId); if ($result['ok']) { - app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); + app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId)); +} else { + http_response_code(400); } Router::json($result); diff --git a/pages/bookmarks/reorder-data().php b/modules/bookmarks/pages/bookmarks/reorder-data().php similarity index 55% rename from pages/bookmarks/reorder-data().php rename to modules/bookmarks/pages/bookmarks/reorder-data().php index 09c3488..e3c5b76 100644 --- a/pages/bookmarks/reorder-data().php +++ b/modules/bookmarks/pages/bookmarks/reorder-data().php @@ -2,7 +2,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; -use MintyPHP\Service\Bookmark\BookmarkService; +use MintyPHP\Module\Bookmarks\Service\BookmarkService; use MintyPHP\Session; use MintyPHP\Support\Guard; @@ -22,18 +22,17 @@ if (!Session::checkCsrfToken()) { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); -$type = trim((string) requestInput()->body('type')); -$allowedTypes = ['groups', 'bookmarks', 'root']; -if (!in_array($type, $allowedTypes, true)) { - http_response_code(400); - Router::json(['ok' => false, 'error' => 'invalid_type']); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); return; } +$type = trim((string) requestInput()->body('type')); $rawItems = requestInput()->body('items'); -$items = is_array($rawItems) ? $rawItems : (is_string($rawItems) ? json_decode($rawItems, true) : []); -if (!is_array($items)) { +$items = is_array($rawItems) ? $rawItems : (is_string($rawItems) ? json_decode($rawItems, true) : []); +if (!is_array($items) || $items === []) { http_response_code(400); Router::json(['ok' => false, 'error' => 'invalid_items']); return; @@ -48,15 +47,18 @@ if ($type === 'root') { foreach ($items as $item) { if (!is_array($item)) { - continue; + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; } $kind = trim((string) ($item['kind'] ?? '')); $id = (int) ($item['id'] ?? 0); $sortOrder = (int) ($item['sort_order'] ?? 0); if (($kind !== 'group' && $kind !== 'bookmark') || $id <= 0 || $sortOrder <= 0) { - continue; + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; } - $dedupeKey = $kind . ':' . $id; if (isset($seenRootIds[$dedupeKey]) || isset($seenSortOrders[$sortOrder])) { http_response_code(400); @@ -65,58 +67,44 @@ if ($type === 'root') { } $seenRootIds[$dedupeKey] = true; $seenSortOrders[$sortOrder] = true; - $rootPairs[] = [ - 'kind' => $kind, - 'id' => $id, - 'sort_order' => $sortOrder, - ]; + $rootPairs[] = ['kind' => $kind, 'id' => $id, 'sort_order' => $sortOrder]; } - $pairCount = count($rootPairs); - if ($pairCount === 0 || $pairCount !== count($items)) { - http_response_code(400); - Router::json(['ok' => false, 'error' => 'invalid_items']); - return; - } - - $ok = $service->reorderRoot($userId, $rootPairs); + $result = $service->reorderRootFromItems($userId, $rootPairs); } else { $pairs = []; $seenIds = []; + foreach ($items as $item) { if (!is_array($item)) { - continue; + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; } $id = (int) ($item['id'] ?? 0); $sortOrder = (int) ($item['sort_order'] ?? 0); - if ($id > 0 && $sortOrder > 0) { - if (isset($seenIds[$id])) { - http_response_code(400); - Router::json(['ok' => false, 'error' => 'invalid_items']); - return; - } - $seenIds[$id] = true; - $pairs[] = ['id' => $id, 'sort_order' => $sortOrder]; + if ($id <= 0 || $sortOrder <= 0) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; } + if (isset($seenIds[$id])) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; + } + $seenIds[$id] = true; + $pairs[] = ['id' => $id, 'sort_order' => $sortOrder]; } - $pairCount = count($pairs); - if ($pairCount === 0 || $pairCount !== count($items)) { - http_response_code(400); - Router::json(['ok' => false, 'error' => 'invalid_items']); - return; - } - - $ok = $type === 'groups' - ? $service->reorderGroups($userId, $pairs) - : $service->reorderBookmarks($userId, $pairs); + $result = $service->reorderFromItems($userId, $type, $pairs); } -if ($ok) { - app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); +if ($result['ok']) { + app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId)); Router::json(['ok' => true]); return; } http_response_code(400); -Router::json(['ok' => false, 'error' => 'reorder_failed']); +Router::json($result); diff --git a/pages/bookmarks/save-data().php b/modules/bookmarks/pages/bookmarks/save-data().php similarity index 73% rename from pages/bookmarks/save-data().php rename to modules/bookmarks/pages/bookmarks/save-data().php index 0eb11bb..3369362 100644 --- a/pages/bookmarks/save-data().php +++ b/modules/bookmarks/pages/bookmarks/save-data().php @@ -2,7 +2,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; -use MintyPHP\Service\Bookmark\BookmarkService; +use MintyPHP\Module\Bookmarks\Service\BookmarkService; use MintyPHP\Session; use MintyPHP\Support\Guard; @@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + $name = trim((string) requestInput()->body('name')); $url = trim((string) requestInput()->body('url')); $groupId = requestInput()->body('group_id'); @@ -31,7 +37,9 @@ $service = app(BookmarkService::class); $result = $service->saveBookmark($userId, $name, $url, $groupId); if ($result['ok']) { - app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); + app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId)); +} else { + http_response_code(400); } Router::json($result); diff --git a/pages/bookmarks/update-data().php b/modules/bookmarks/pages/bookmarks/update-data().php similarity index 88% rename from pages/bookmarks/update-data().php rename to modules/bookmarks/pages/bookmarks/update-data().php index 3fd859c..f900396 100644 --- a/pages/bookmarks/update-data().php +++ b/modules/bookmarks/pages/bookmarks/update-data().php @@ -2,7 +2,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Router; -use MintyPHP\Service\Bookmark\BookmarkService; +use MintyPHP\Module\Bookmarks\Service\BookmarkService; use MintyPHP\Session; use MintyPHP\Support\Guard; @@ -47,7 +47,7 @@ $service = app(BookmarkService::class); $result = $service->updateBookmark($userId, $bookmarkId, $data); if ($result['ok']) { - app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); + app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId)); } else { http_response_code(400); } diff --git a/modules/bookmarks/templates/aside-bookmarks-panel.phtml b/modules/bookmarks/templates/aside-bookmarks-panel.phtml new file mode 100644 index 0000000..f1da638 --- /dev/null +++ b/modules/bookmarks/templates/aside-bookmarks-panel.phtml @@ -0,0 +1,242 @@ + [], 'ungrouped' => []]; +$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : []; +$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : []; +$currentPath = trim((string) ($bookmarkNav['current_path'] ?? '')); +?> +
+ + t('No bookmarks yet'), + 'size' => 'compact', + 'align' => 'center', + ]; + require templatePath('partials/app-empty-state.phtml'); + ?> + + 'bookmark', + 'id' => $bookmarkId, + 'sort_order' => (int) ($bookmark['sort_order'] ?? 0), + 'bookmark' => $bookmark, + ]; + } + + foreach ($bookmarkGroups as $group) { + $groupId = (int) ($group['id'] ?? 0); + if ($groupId <= 0) { + continue; + } + $bookmarkRootItems[] = [ + 'type' => 'group', + 'id' => $groupId, + 'sort_order' => (int) ($group['sort_order'] ?? 0), + 'group' => $group, + ]; + } + + usort($bookmarkRootItems, static function (array $a, array $b): int { + $sortCmp = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0)); + if ($sortCmp !== 0) { + return $sortCmp; + } + $typeCmp = strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? '')); + if ($typeCmp !== 0) { + return $typeCmp; + } + return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0)); + }); + + $rootCount = count($bookmarkRootItems); + ?> +
    + $rootItem): ?> + + + +
  • + > + + +
    + + + + +
    +
  • + + +
  • +
    +
    + + + + +
    + + + + +
    +
    + +
      + + $bookmark): ?> + +
    • + > + + +
      + + + + +
      +
    • + +
    + +
    +
  • + + +
+ +
diff --git a/templates/partials/app-bookmark-dialog.phtml b/modules/bookmarks/templates/bookmark-dialogs.phtml similarity index 80% rename from templates/partials/app-bookmark-dialog.phtml rename to modules/bookmarks/templates/bookmark-dialogs.phtml index c05c4bf..70a64ef 100644 --- a/templates/partials/app-bookmark-dialog.phtml +++ b/modules/bookmarks/templates/bookmark-dialogs.phtml @@ -1,11 +1,11 @@ get('user_bookmarks')) ? app(SessionStoreInterface::class)->get('user_bookmarks') : []; -$bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['groups'] : []; +use MintyPHP\Module\Bookmarks\Service\BookmarkService; +$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; +$bookmarkNav = is_array($layoutNav['bookmarks.nav'] ?? null) ? $layoutNav['bookmarks.nav'] : []; +$bookmarkData = is_array($bookmarkNav['grouped'] ?? null) ? $bookmarkNav['grouped'] : []; +$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : []; ?>
+
@@ -89,6 +90,7 @@ $bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['
+
diff --git a/modules/bookmarks/templates/topbar-bookmark-button.phtml b/modules/bookmarks/templates/topbar-bookmark-button.phtml new file mode 100644 index 0000000..b8ac5a8 --- /dev/null +++ b/modules/bookmarks/templates/topbar-bookmark-button.phtml @@ -0,0 +1,46 @@ + [], 'ungrouped' => []]; +$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : []; +$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : []; +$currentPath = trim((string) ($bookmarkNav['current_path'] ?? '')); + +$existing = null; +if ($currentPath !== '') { + foreach ($bookmarkUngrouped as $bookmark) { + $bookmarkUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bookmark['url'] ?? '')); + if ($bookmarkUrl !== '' && $bookmarkUrl === $currentPath) { + $existing = $bookmark; + break; + } + } + + if ($existing === null) { + foreach ($bookmarkGroups as $group) { + foreach ((is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : []) as $bookmark) { + $bookmarkUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bookmark['url'] ?? '')); + if ($bookmarkUrl !== '' && $bookmarkUrl === $currentPath) { + $existing = $bookmark; + break 2; + } + } + } + } +} +?> +
  • + +
  • diff --git a/tests/Service/Bookmark/BookmarkServiceTest.php b/modules/bookmarks/tests/Module/Bookmarks/Service/BookmarkServiceTest.php similarity index 98% rename from tests/Service/Bookmark/BookmarkServiceTest.php rename to modules/bookmarks/tests/Module/Bookmarks/Service/BookmarkServiceTest.php index 709e6a5..7a76084 100644 --- a/tests/Service/Bookmark/BookmarkServiceTest.php +++ b/modules/bookmarks/tests/Module/Bookmarks/Service/BookmarkServiceTest.php @@ -1,11 +1,11 @@ assertSame('admin/users?a=1&b=2', $actual); + } + + public function testCanonicalizeRelativeSortsMultiValueQueryKeys(): void + { + $actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?roles=2&roles=1&search=max'); + + $this->assertSame('admin/users?roles=1&roles=2&search=max', $actual); + } + + public function testCanonicalizeRelativeRejectsAbsoluteUrl(): void + { + $actual = BookmarkUrlNormalizer::canonicalizeRelative('https://example.com/admin/users'); + + $this->assertSame('', $actual); + } + + public function testCanonicalizeRelativeRemovesFragment(): void + { + $actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?a=1#section-2'); + + $this->assertSame('admin/users?a=1', $actual); + } + + public function testCanonicalizeRequestUriStripsLocaleBaseAndSortsQuery(): void + { + $actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/de/admin/users?b=2&a=1', '/de/'); + + $this->assertSame('admin/users?a=1&b=2', $actual); + } + + public function testCanonicalizeRequestUriHandlesAppBaseAndLocale(): void + { + $actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/core/de/address-book?tenants=b&tenants=a', '/core/de/'); + + $this->assertSame('address-book?tenants=a&tenants=b', $actual); + } + + public function testCanonicalizeRelativeRejectsDataUri(): void + { + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('data:text/html,')); + } + + public function testCanonicalizeRelativeRejectsJavaScriptUri(): void + { + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('javascript:alert(1)')); + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('JavaScript:alert(document.cookie)')); + } + + public function testCanonicalizeRelativeRejectsFileUri(): void + { + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('file:///etc/passwd')); + } + + public function testCanonicalizeRelativeRejectsVbScriptUri(): void + { + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('vbscript:MsgBox("xss")')); + } + + public function testCanonicalizeRelativeRejectsNullBytes(): void + { + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative("admin/users\x00javascript:alert(1)")); + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative("\x00")); + } + + public function testCanonicalizeRelativeRejectsProtocolRelativeUrl(): void + { + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('//evil.com/payload')); + } + + public function testCanonicalizeRelativeHandlesDeepPathTraversal(): void + { + $this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('admin/users/../../../../etc/passwd')); + } +} diff --git a/web/css/components/app-bookmark-form.css b/modules/bookmarks/web/css/components/app-bookmark-form.css similarity index 92% rename from web/css/components/app-bookmark-form.css rename to modules/bookmarks/web/css/components/app-bookmark-form.css index 7697fad..953c2ef 100644 --- a/web/css/components/app-bookmark-form.css +++ b/modules/bookmarks/web/css/components/app-bookmark-form.css @@ -53,18 +53,9 @@ dialog[data-app-bookmark-dialog] form footer, dialog[data-app-bookmark-group-dialog] form footer { - display: flex; - align-items: center; - gap: 0.5rem; - justify-content: flex-end; padding-top: var(--app-spacing); } - dialog[data-app-bookmark-dialog] form footer button, - dialog[data-app-bookmark-group-dialog] form footer button { - margin: 0; - } - .app-bookmark-delete-btn { color: var(--app-del-color, #c62828); border-color: var(--app-del-color, #c62828); diff --git a/modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css b/modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css new file mode 100644 index 0000000..cc4a1b4 --- /dev/null +++ b/modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css @@ -0,0 +1,185 @@ +@layer layout { + .app-sidebar .app-sidebar-bookmark-item > a > i { + flex-shrink: 0; + width: 16px; + text-align: center; + } + + li.app-sidebar-bookmark-root-item.app-sidebar-bookmark-item:has(+ .app-sidebar-group) { + margin-bottom: var(--app-spacing); + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item { + display: flex; + align-items: center; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-shell { + position: relative; + display: flex; + flex-direction: column; + gap: 0.2rem; + width: 100%; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-left: var(--app-spacing); + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; + margin: 0; + color: var(--app-muted-color); + font-size: 0.72rem; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > span { + flex: 1; + min-width: 0; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > i { + flex-shrink: 0; + width: 16px; + text-align: center; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu { + margin: 0; + margin-right: 5px; + position: relative; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu summary:after { + display: none; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary { + list-style: none; + border: 0; + background: transparent; + color: var(--app-muted-color); + padding: 0.25rem; + min-width: 1.5rem; + min-height: 1.5rem; + line-height: 1; + margin: 0; + border-radius: 3px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: + color 120ms ease, + background 120ms ease, + opacity 120ms ease; + opacity: 0; + pointer-events: none; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item:hover .app-sidebar-bookmark-action-menu > summary, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item:focus-within .app-sidebar-bookmark-action-menu > summary, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header:hover .app-sidebar-bookmark-action-menu > summary, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header:focus-within .app-sidebar-bookmark-action-menu > summary, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu[open] > summary, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary:focus-visible { + opacity: 1; + pointer-events: auto; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary::-webkit-details-marker { + display: none; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary i { + font-size: 0.95rem; + pointer-events: none; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu[open] > summary, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary:hover, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary:focus-visible { + color: var(--app-contrast); + background: color-mix(in srgb, var(--app-contrast) 10%, transparent); + } + + @media (hover: none), (pointer: coarse) { + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary { + opacity: 1; + pointer-events: auto; + } + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list { + position: absolute; + inset-inline-end: 0; + inset-block-start: calc(100% + 4px); + min-width: 10rem; + max-width: calc(220px - 2 * var(--app-spacing)); + border: 1px solid var(--app-border); + border-radius: var(--app-border-radius); + background: var(--app-card-bg, var(--app-background-color)); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.14), + 0 1px 3px rgba(0, 0, 0, 0.08); + padding: 0.25rem; + z-index: 10; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list > li { + margin: 0; + padding: 0; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: start; + padding: 0.375rem 0.5rem; + margin: 0; + border-radius: 3px; + cursor: pointer; + white-space: nowrap; + font-size: 0.8125rem; + line-height: 1.4; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button:hover, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button:focus-visible { + background: color-mix(in srgb, var(--app-contrast) 8%, transparent); + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button[disabled] { + opacity: 0.45; + cursor: not-allowed; + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list li:has(> .app-sidebar-bookmark-action-danger) { + margin-top: 0.2rem; + padding-top: 0.2rem; + border-top: 1px solid var(--app-border); + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger { + color: var(--app-del-color, #c62828); + } + + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger:hover, + .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger:focus-visible { + background: rgba(198, 40, 40, 0.12); + } +} diff --git a/web/js/components/app-bookmark-panel.js b/modules/bookmarks/web/js/components/app-bookmark-panel.js similarity index 91% rename from web/js/components/app-bookmark-panel.js rename to modules/bookmarks/web/js/components/app-bookmark-panel.js index a719aef..f67adcd 100644 --- a/web/js/components/app-bookmark-panel.js +++ b/modules/bookmarks/web/js/components/app-bookmark-panel.js @@ -4,14 +4,19 @@ * - Reorder bookmarks inside groups (up/down) * - Edit/Delete groups and bookmarks with confirm dialog */ -import { showAsyncFlash } from './app-async-flash.js'; -import { warnOnce } from '../core/app-dom.js'; -import { confirmDialog } from '../core/app-confirm-dialog.js'; +import { showAsyncFlash } from '../../../../js/components/app-async-flash.js'; +import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js'; +import { confirmDialog } from '../../../../js/core/app-confirm-dialog.js'; -export function init() { - const panel = document.querySelector('[data-aside-panel="bookmarks"]'); +export function initBookmarkPanel(root = document, options = {}) { + const host = resolveHost(root); + const panelSelector = String(options.selector || '[data-aside-panel="bookmarks"]').trim() || '[data-aside-panel="bookmarks"]'; + const panel = host.matches?.(panelSelector) ? host : host.querySelector(panelSelector); if (!(panel instanceof HTMLElement)) { - return; + return { destroy: () => {} }; + } + if (panel.dataset.bookmarkPanelBound === '1' && panel._bookmarkPanelApi) { + return panel._bookmarkPanelApi; } const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim(); @@ -19,16 +24,19 @@ export function init() { const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim(); if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) { warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' }); - return; + return { destroy: () => {} }; } - const root = document.documentElement; - const csrfKey = String(root.dataset.csrfKey || '').trim(); - const csrfToken = String(root.dataset.csrfToken || '').trim(); + const documentRoot = document.documentElement; + const csrfKey = String(documentRoot.dataset.csrfKey || '').trim(); + const csrfToken = String(documentRoot.dataset.csrfToken || '').trim(); if (!csrfKey || !csrfToken) { warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark panel', { module: 'bookmark-panel' }); - return; + return { destroy: () => {} }; } + const abortController = new AbortController(); + const { signal } = abortController; + const timerIds = []; const messageDeleteConfirm = panel.dataset.bookmarkMsgGroupDeleteConfirm || 'Delete group and keep bookmarks?'; const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted'; @@ -93,7 +101,8 @@ export function init() { if (String(successMessage).trim() !== '') { showAsyncFlash('success', successMessage, 2500); } - window.setTimeout(() => window.location.reload(), 450); + const timerId = window.setTimeout(() => window.location.reload(), 450); + timerIds.push(timerId); }; const directChildren = (container, className) => { @@ -494,7 +503,7 @@ export function init() { void reorderBookmark(itemActionButton); } } - }); + }, { signal }); document.addEventListener('click', (event) => { if (!(event.target instanceof Node)) { @@ -504,5 +513,17 @@ export function init() { if (!clickedInsideMenu) { closeActionMenus(); } - }); + }, { signal }); + + const destroy = () => { + abortController.abort(); + timerIds.forEach((timerId) => window.clearTimeout(timerId)); + timerIds.length = 0; + delete panel.dataset.bookmarkPanelBound; + delete panel._bookmarkPanelApi; + }; + const api = { destroy }; + panel.dataset.bookmarkPanelBound = '1'; + panel._bookmarkPanelApi = api; + return api; } diff --git a/web/js/components/app-bookmark-save.js b/modules/bookmarks/web/js/components/app-bookmark-save.js similarity index 92% rename from web/js/components/app-bookmark-save.js rename to modules/bookmarks/web/js/components/app-bookmark-save.js index 5f97cb1..878a7ed 100644 --- a/web/js/components/app-bookmark-save.js +++ b/modules/bookmarks/web/js/components/app-bookmark-save.js @@ -3,17 +3,20 @@ * Bookmark dialog (create/update) + group dialog (create/update). */ -import { showAsyncFlash } from './app-async-flash.js'; -import { warnOnce } from '../core/app-dom.js'; -import { getAppBase } from '../pages/app-list-utils.js'; +import { showAsyncFlash } from '../../../../js/components/app-async-flash.js'; +import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js'; +import { getAppBase } from '../../../../js/pages/app-list-utils.js'; -export function initBookmarkSave(root = document) { - const host = root && typeof root.querySelector === 'function' ? root : document; - const openBtn = host.querySelector('[data-bookmark-open-dialog]') || document.querySelector('[data-bookmark-open-dialog]'); - const bookmarkDialog = host.matches?.('[data-app-bookmark-dialog]') +export function initBookmarkSave(root = document, options = {}) { + const host = resolveHost(root); + const openButtonSelector = String(options.openButtonSelector || '[data-bookmark-open-dialog]').trim() || '[data-bookmark-open-dialog]'; + const bookmarkDialogSelector = String(options.dialogSelector || '[data-app-bookmark-dialog]').trim() || '[data-app-bookmark-dialog]'; + const groupDialogSelector = String(options.groupDialogSelector || '[data-app-bookmark-group-dialog]').trim() || '[data-app-bookmark-group-dialog]'; + const openBtn = host.querySelector(openButtonSelector); + const bookmarkDialog = host.matches?.(bookmarkDialogSelector) ? host - : (host.querySelector('[data-app-bookmark-dialog]') || document.querySelector('[data-app-bookmark-dialog]')); - if (!(bookmarkDialog instanceof HTMLDialogElement) || !(openBtn instanceof HTMLButtonElement)) { + : host.querySelector(bookmarkDialogSelector); + if (!(bookmarkDialog instanceof HTMLDialogElement)) { return { destroy: () => {} }; } if (bookmarkDialog.dataset.bookmarkSaveBound === '1' && bookmarkDialog._bookmarkSaveApi) { @@ -25,7 +28,7 @@ export function initBookmarkSave(root = document) { return { destroy: () => {} }; } - const groupDialog = document.querySelector('[data-app-bookmark-group-dialog]'); + const groupDialog = host.querySelector(groupDialogSelector); const groupForm = groupDialog instanceof HTMLDialogElement ? groupDialog.querySelector('[data-bookmark-group-save-form]') : null; @@ -148,6 +151,17 @@ export function initBookmarkSave(root = document) { }; const configureBookmarkModeFromTopbar = () => { + if (!(openBtn instanceof HTMLButtonElement)) { + applyBookmarkModeUi(false); + editingBookmarkId = null; + bookmarkForm.reset(); + if (nameInput) { + const title = document.title.replace(/\s*[|–—].*$/, '').trim(); + nameInput.value = title || ''; + } + return; + } + const isEditMode = openBtn.hasAttribute('data-bookmark-existing-id'); applyBookmarkModeUi(isEditMode); @@ -220,12 +234,14 @@ export function initBookmarkSave(root = document) { }; // Bookmark dialog open - openBtn.addEventListener('click', () => { - configureBookmarkModeFromTopbar(); - if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) { - nameInput.select(); - } - }, { signal }); + if (openBtn instanceof HTMLButtonElement) { + openBtn.addEventListener('click', () => { + configureBookmarkModeFromTopbar(); + if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) { + nameInput.select(); + } + }, { signal }); + } document.addEventListener('app:bookmark-edit', (event) => { const detail = event instanceof CustomEvent ? event.detail : null; @@ -607,8 +623,7 @@ export function initBookmarkSave(root = document) { } function currentRelativeUrl() { - const baseEl = document.querySelector('base'); - const basePath = baseEl ? new URL(baseEl.href).pathname : '/'; + const basePath = new URL(getAppBase()).pathname; const full = window.location.pathname + window.location.search; if (full.startsWith(basePath)) { return full.slice(basePath.length); diff --git a/tests/Architecture/FrontendComponentRuntimeContractTest.php b/tests/Architecture/FrontendComponentRuntimeContractTest.php new file mode 100644 index 0000000..b9211ad --- /dev/null +++ b/tests/Architecture/FrontendComponentRuntimeContractTest.php @@ -0,0 +1,227 @@ +readProjectFile('web/js/app-init.js'); + $loginEntry = $this->readProjectFile('web/js/app-login-init.js'); + + $this->assertStringContainsString("import { createComponentRuntime } from './core/app-component-runtime.js';", $defaultEntry); + $this->assertStringContainsString("import { createComponentRuntime } from './core/app-component-runtime.js';", $loginEntry); + $this->assertDoesNotMatchRegularExpression('/import\s+[\'"]\.\/components\//', $defaultEntry); + $this->assertDoesNotMatchRegularExpression('/import\s+[\'"]\.\/components\//', $loginEntry); + + $this->assertStringContainsString("runtime.register('sidebar-toggle', initSidebarToggle", $defaultEntry); + $this->assertStringContainsString("runtime.register('global-search', initGlobalSearch", $defaultEntry); + $this->assertStringContainsString("await mountModuleComponentsByPhase('late');", $defaultEntry); + $this->assertStringContainsString('moduleRuntimeComponents', $defaultEntry); + $this->assertStringContainsString("selector: '[data-app-component=\"tabs\"]'", $defaultEntry); + $this->assertStringContainsString('runtime.mountAll();', $defaultEntry); + + $this->assertStringContainsString("runtime.register('password-hints', initPasswordHints", $loginEntry); + $this->assertStringContainsString("runtime.register('password-toggle', initPasswordToggles", $loginEntry); + $this->assertStringContainsString("runtime.register('login-submit-lock', initLoginSubmitLock", $loginEntry); + $this->assertStringContainsString('runtime.mountAll();', $loginEntry); + } + + public function testRuntimeMigratedComponentsDoNotAutoInitialize(): void + { + $componentFiles = [ + 'web/js/components/app-flash-auto-dismiss.js', + 'web/js/components/app-fslightbox-refresh.js', + 'web/js/components/app-password-hints.js', + 'web/js/components/app-copy-badge.js', + 'web/js/components/app-multiselect-init.js', + 'web/js/components/app-nav-history.js', + 'web/js/components/app-toggle-sidebar.js', + 'web/js/components/app-toggle-aside-panels.js', + 'web/js/components/app-toggle-details-aside.js', + 'web/js/components/app-global-search.js', + 'web/js/components/app-theme-toggle.js', + 'web/js/components/app-contrast-toggle.js', + 'web/js/components/app-confirm-actions.js', + 'web/js/components/app-details-state.js', + 'web/js/components/app-tenant-switcher.js', + 'web/js/components/app-color-default-toggle.js', + 'web/js/components/app-custom-field-options-toggle.js', + 'web/js/components/app-tenant-sso-toggle.js', + 'web/js/components/app-settings-telemetry.js', + 'web/js/components/app-standard-detail-page.js', + 'web/js/components/app-auto-submit.js', + 'modules/bookmarks/web/js/components/app-bookmark-save.js', + 'modules/bookmarks/web/js/components/app-bookmark-panel.js', + 'web/js/components/app-password-toggle.js', + 'web/js/components/app-page-editor.js', + ]; + + foreach ($componentFiles as $file) { + $content = $this->readProjectFile($file); + $this->assertStringNotContainsString('DOMContentLoaded', $content, $file); + $this->assertStringNotContainsString("if (document.readyState === 'loading')", $content, $file); + } + } + + public function testMigratedComponentsExposeDestroyCleanupPaths(): void + { + $sidebar = $this->readProjectFile('web/js/components/app-toggle-sidebar.js'); + $aside = $this->readProjectFile('web/js/components/app-toggle-aside-panels.js'); + $search = $this->readProjectFile('web/js/components/app-global-search.js'); + $bookmarkSave = $this->readProjectFile('modules/bookmarks/web/js/components/app-bookmark-save.js'); + $bookmarkPanel = $this->readProjectFile('modules/bookmarks/web/js/components/app-bookmark-panel.js'); + $multiSelect = $this->readProjectFile('web/js/components/app-multiselect-init.js'); + + $this->assertStringContainsString('destroy: () => {', $sidebar); + $this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onShortcut));", $sidebar); + + $this->assertStringContainsString('destroy: () => {', $aside); + $this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onKeyDown));", $aside); + + $this->assertStringContainsString('const destroy = () => {', $search); + $this->assertStringContainsString("document.removeEventListener('keydown', onDocumentKeydown);", $search); + + $this->assertStringContainsString('const destroy = () => {', $bookmarkSave); + $this->assertStringContainsString('abortController.abort();', $bookmarkSave); + + $this->assertStringContainsString('const destroy = () => {', $bookmarkPanel); + $this->assertStringContainsString('abortController.abort();', $bookmarkPanel); + $this->assertStringNotContainsString('export const init =', $bookmarkSave); + $this->assertStringNotContainsString('export const init =', $bookmarkPanel); + + $this->assertStringContainsString('return {', $multiSelect); + $this->assertStringContainsString('destroy: () => {', $multiSelect); + } + + public function testBookmarkLegacyEntrypointFileIsRemoved(): void + { + $this->assertFileDoesNotExist($this->projectRootPath() . '/web/js/components/app-bookmark-init.js'); + } + + public function testRuntimeStorageContractUsesSharedUiStorageHelper(): void + { + $files = [ + 'web/js/components/app-toggle-sidebar.js', + 'web/js/components/app-toggle-aside-panels.js', + 'web/js/components/app-toggle-details-aside.js', + 'web/js/components/app-contrast-toggle.js', + 'web/js/components/app-tabs.js', + 'web/js/core/app-details-open-state.js', + 'web/js/core/app-grid-factory.js', + ]; + + foreach ($files as $file) { + $content = $this->readProjectFile($file); + $this->assertStringNotContainsString('window.localStorage', $content, $file); + $this->assertStringContainsString('createUiStorage', $content, $file); + } + } + + public function testHostBoundComponentsExposeDataAppComponentMarkupHosts(): void + { + $defaultTemplate = $this->readProjectFile('templates/default.phtml'); + $loginTemplate = $this->readProjectFile('templates/login.phtml'); + $flashPartial = $this->readProjectFile('templates/partials/app-flash.phtml'); + $topbarPartial = $this->readProjectFile('templates/partials/app-topbar.phtml'); + $asideIconBar = $this->readProjectFile('templates/partials/app-main-aside-icon-bar.phtml'); + $asidePartial = $this->readProjectFile('templates/partials/app-main-aside.phtml'); + $bookmarkDialog = $this->readProjectFile('modules/bookmarks/templates/bookmark-dialogs.phtml'); + $bookmarkPanelTemplate = $this->readProjectFile('modules/bookmarks/templates/aside-bookmarks-panel.phtml'); + $sessionWarningDialog = $this->readProjectFile('templates/partials/app-session-warning-dialog.phtml'); + + $this->assertStringContainsString('id="page-config-app-components"', $defaultTemplate); + $this->assertStringContainsString('id="page-config-login-components"', $loginTemplate); + $this->assertStringNotContainsString("assetVersion('js/components/app-bookmark-init.js')", $defaultTemplate); + + $this->assertStringContainsString('data-app-component="flash-auto-dismiss"', $flashPartial); + $this->assertStringContainsString('data-app-component="nav-history"', $topbarPartial); + $this->assertStringContainsString('data-app-component="tenant-switcher"', $topbarPartial); + $this->assertStringContainsString('data-app-component="theme-controls"', $topbarPartial); + $this->assertStringContainsString('data-app-component="contrast-toggle"', $topbarPartial); + $this->assertStringContainsString('data-app-component="details-aside-toggle"', $topbarPartial); + $this->assertStringContainsString('data-app-component="aside-panels"', $asideIconBar); + $this->assertStringContainsString('data-app-component="sidebar-toggle"', $asidePartial); + $this->assertStringContainsString('data-app-component="global-search"', $asidePartial); + $this->assertStringContainsString('data-app-component="bookmark-panel"', $bookmarkPanelTemplate); + $this->assertStringContainsString('data-app-component="bookmark-save"', $bookmarkDialog); + $this->assertStringContainsString('data-app-component="session-warning"', $sessionWarningDialog); + } + + public function testTabsHostsUseDataAppComponentContract(): void + { + $files = [ + 'modules/addressbook/pages/address-book/view(default).phtml', + 'pages/admin/departments/_form.phtml', + 'pages/admin/permissions/_form.phtml', + 'pages/admin/roles/_form.phtml', + 'pages/admin/settings/index(default).phtml', + 'pages/admin/stats/index(default).phtml', + 'pages/admin/tenants/_form.phtml', + 'pages/admin/users/_form.phtml', + ]; + + foreach ($files as $file) { + $content = $this->readProjectFile($file); + $this->assertStringContainsString('data-tabs', $content, $file); + $this->assertStringContainsString('data-app-component="tabs"', $content, $file); + } + } + + public function testPageEditorUsesPageModuleEntrypointWithPageConfig(): void + { + $pageEditor = $this->readProjectFile('web/js/components/app-page-editor.js'); + $pageEditorEntry = $this->readProjectFile('web/js/pages/app-page-editor-entry.js'); + $adminPage = $this->readProjectFile('pages/page/index(default).phtml'); + $publicPage = $this->readProjectFile('pages/public-page/index(page).phtml'); + + $this->assertStringContainsString('export function initPageEditor(root = document, config = {})', $pageEditor); + $this->assertStringNotContainsString('const form = document.querySelector', $pageEditor); + $this->assertStringContainsString("readPageConfig('page-editor')", $pageEditorEntry); + $this->assertStringContainsString("assetVersion('js/pages/app-page-editor-entry.js')", $adminPage); + $this->assertStringContainsString("assetVersion('js/pages/app-page-editor-entry.js')", $publicPage); + $this->assertStringNotContainsString("assetVersion('js/components/app-page-editor.js')", $adminPage); + $this->assertStringNotContainsString("assetVersion('js/components/app-page-editor.js')", $publicPage); + $this->assertStringContainsString('id="page-config-page-editor"', $adminPage); + $this->assertStringContainsString('id="page-config-page-editor"', $publicPage); + } + + public function testNoCustomWindowGlobalsRemainInWebJs(): void + { + $violations = $this->findPatternMatchesInFiles( + 'web/js', + '/window\.(AppSidebar|AppAsidePanels|requestFsLightboxRefresh|__fsLightboxRefreshPending|APP_ASSET_BASE|APP_BASE)\b/', + ['js'] + ); + + $this->assertSame([], $violations, 'Custom window globals found in web/js: ' . implode(', ', $violations)); + } + + public function testHostBoundRuntimeComponentsAreRootStrictWithoutDocumentFallbackQuery(): void + { + $files = [ + 'web/js/components/app-global-search.js', + 'modules/bookmarks/web/js/components/app-bookmark-panel.js', + 'web/js/components/app-tabs.js', + ]; + + foreach ($files as $file) { + $content = $this->readProjectFile($file); + $this->assertDoesNotMatchRegularExpression('/\|\|\s*document\.querySelector/', $content, $file); + } + } + + public function testDomBindingUtilitiesExposeDestroyLifecycle(): void + { + $activeFilterChips = $this->readProjectFile('web/js/components/app-active-filter-chips.js'); + $multiselectCascade = $this->readProjectFile('web/js/components/app-multiselect-cascade.js'); + + $this->assertStringContainsString('return { render, destroy };', $activeFilterChips); + $this->assertStringContainsString('return {', $multiselectCascade); + $this->assertStringContainsString('destroy: () => {', $multiselectCascade); + } +} diff --git a/tests/Architecture/NoBookmarksHardcodingTest.php b/tests/Architecture/NoBookmarksHardcodingTest.php new file mode 100644 index 0000000..e514083 --- /dev/null +++ b/tests/Architecture/NoBookmarksHardcodingTest.php @@ -0,0 +1,84 @@ + str_starts_with($f, 'Bookmark')); + self::assertSame( + [], + array_values($bookmarkFiles), + 'Core lib/Repository/User/ still contains Bookmark files: ' . implode(', ', $bookmarkFiles) + ); + } + + public function testCoreNoLongerContainsBookmarkUrlNormalizer(): void + { + $root = dirname(__DIR__, 2); + self::assertFileDoesNotExist( + $root . '/lib/Support/BookmarkUrlNormalizer.php', + 'BookmarkUrlNormalizer should live in modules/bookmarks/lib, not in Core lib/Support' + ); + } + + public function testCoreAuthLifecycleHasNoLegacyBookmarkSessionKeyWrites(): void + { + $file = dirname(__DIR__, 2) . '/lib/Service/Auth/AuthSessionTenantContextService.php'; + $content = file_get_contents($file); + self::assertIsString($content); + self::assertStringNotContainsString('user_bookmarks', $content); + self::assertStringNotContainsString('module.bookmarks.grouped', $content); + } +} diff --git a/tests/Support/BookmarkUrlNormalizerTest.php b/tests/Support/BookmarkUrlNormalizerTest.php deleted file mode 100644 index a51b94b..0000000 --- a/tests/Support/BookmarkUrlNormalizerTest.php +++ /dev/null @@ -1,51 +0,0 @@ -assertSame('admin/users?a=1&b=2', $actual); - } - - public function testCanonicalizeRelativeSortsMultiValueQueryKeys(): void - { - $actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?roles=2&roles=1&search=max'); - - $this->assertSame('admin/users?roles=1&roles=2&search=max', $actual); - } - - public function testCanonicalizeRelativeRejectsAbsoluteUrl(): void - { - $actual = BookmarkUrlNormalizer::canonicalizeRelative('https://example.com/admin/users'); - - $this->assertSame('', $actual); - } - - public function testCanonicalizeRelativeRemovesFragment(): void - { - $actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?a=1#section-2'); - - $this->assertSame('admin/users?a=1', $actual); - } - - public function testCanonicalizeRequestUriStripsLocaleBaseAndSortsQuery(): void - { - $actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/de/admin/users?b=2&a=1', '/de/'); - - $this->assertSame('admin/users?a=1&b=2', $actual); - } - - public function testCanonicalizeRequestUriHandlesAppBaseAndLocale(): void - { - $actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/core/de/address-book?tenants=b&tenants=a', '/core/de/'); - - $this->assertSame('address-book?tenants=a&tenants=b', $actual); - } -} diff --git a/tests/Unit/Module/BookmarksAuthorizationPolicyTest.php b/tests/Unit/Module/BookmarksAuthorizationPolicyTest.php new file mode 100644 index 0000000..a84c929 --- /dev/null +++ b/tests/Unit/Module/BookmarksAuthorizationPolicyTest.php @@ -0,0 +1,45 @@ +supports(BookmarksAuthorizationPolicy::ABILITY_USE)); + self::assertFalse($policy->supports('addressbook.view')); + } + + public function testAuthorizeAllowsAuthenticatedActors(): void + { + $policy = new BookmarksAuthorizationPolicy(); + + $decision = $policy->authorize(BookmarksAuthorizationPolicy::ABILITY_USE, [ + 'actor_user_id' => 7, + ]); + + self::assertTrue($decision->isAllowed()); + } + + public function testAuthorizeDeniesMissingActor(): void + { + $policy = new BookmarksAuthorizationPolicy(); + + $decision = $policy->authorize(BookmarksAuthorizationPolicy::ABILITY_USE, []); + + self::assertFalse($decision->isAllowed()); + self::assertSame(403, $decision->status()); + } +} diff --git a/web/js/components/app-bookmark-init.js b/web/js/components/app-bookmark-init.js deleted file mode 100644 index ea1053d..0000000 --- a/web/js/components/app-bookmark-init.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * app-bookmark-init.js - * Initializes bookmark save form and sidebar panel. - */ - -import { init as initBookmarkSave } from './app-bookmark-save.js'; -import { init as initBookmarkPanel } from './app-bookmark-panel.js'; - -initBookmarkSave(); -initBookmarkPanel(); diff --git a/web/modules/bookmarks b/web/modules/bookmarks new file mode 120000 index 0000000..5dbddf2 --- /dev/null +++ b/web/modules/bookmarks @@ -0,0 +1 @@ +/var/www/modules/bookmarks/web \ No newline at end of file