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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Bookmarks;
|
||||
|
||||
use MintyPHP\Service\Access\AuthorizationDecision;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
|
||||
final class BookmarksAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY_USE = 'bookmarks.use';
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return $ability === self::ABILITY_USE;
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
{
|
||||
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
|
||||
if ($actorUserId <= 0) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
return AuthorizationDecision::allow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Bookmarks;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Module\Bookmarks\Service\BookmarkRepositoryFactory;
|
||||
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||
use MintyPHP\Module\Bookmarks\Service\BookmarkServicesFactory;
|
||||
|
||||
final class BookmarksContainerRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Bookmarks\Providers;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
|
||||
|
||||
final class BookmarksLayoutProvider implements LayoutContextProvider
|
||||
{
|
||||
public function provide(array $session, AppContainer $container): array
|
||||
{
|
||||
$grouped = is_array($session['module.bookmarks.grouped'] ?? null)
|
||||
? $session['module.bookmarks.grouped']
|
||||
: ['groups' => [], 'ungrouped' => []];
|
||||
|
||||
$currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri(
|
||||
(string) ($_SERVER['REQUEST_URI'] ?? ''),
|
||||
localeBase()
|
||||
);
|
||||
|
||||
return [
|
||||
'bookmarks.nav' => [
|
||||
'grouped' => $grouped,
|
||||
'current_path' => $currentPath,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Bookmarks\Providers;
|
||||
|
||||
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||
|
||||
/**
|
||||
* Provides the bookmarks search resource for the global search.
|
||||
*
|
||||
* Bookmarks are user-scoped: the SQL uses {{userId}} so only
|
||||
* the current user's bookmarks are searched.
|
||||
*/
|
||||
final class BookmarksSearchProvider implements SearchResourceProvider
|
||||
{
|
||||
public function resources(): array
|
||||
{
|
||||
return [
|
||||
'bookmarks' => [
|
||||
'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 '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Bookmarks\Providers;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||
|
||||
final class BookmarksSessionProvider implements SessionProvider
|
||||
{
|
||||
private const SESSION_KEY = 'module.bookmarks.grouped';
|
||||
|
||||
public function populate(array $user, AppContainer $container): void
|
||||
{
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
unset($_SESSION[self::SESSION_KEY]);
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStore = $container->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]);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||
|
||||
interface BookmarkGroupRepositoryInterface
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
@@ -36,9 +36,9 @@ class BookmarkNavigationRepository implements BookmarkNavigationRepositoryInterf
|
||||
$bookmarkIds = [];
|
||||
|
||||
foreach ($rootOrderPairs as $pair) {
|
||||
$kind = trim((string) ($pair['kind'] ?? ''));
|
||||
$id = (int) ($pair['id'] ?? 0);
|
||||
$sortOrder = (int) ($pair['sort_order'] ?? 0);
|
||||
$kind = trim((string) $pair['kind']);
|
||||
$id = (int) $pair['id'];
|
||||
$sortOrder = (int) $pair['sort_order'];
|
||||
if ($id <= 0 || $sortOrder <= 0 || ($kind !== 'group' && $kind !== 'bookmark')) {
|
||||
return false;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||
|
||||
interface BookmarkNavigationRepositoryInterface
|
||||
{
|
||||
@@ -1,34 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
class BookmarkRepository implements BookmarkRepositoryInterface
|
||||
{
|
||||
private function unwrapList(mixed $rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_bookmarks'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'user_bookmarks');
|
||||
}
|
||||
|
||||
public function listByUser(int $userId): array
|
||||
public function listByUser(int $userId, array $options = []): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options, 100, 1, 100);
|
||||
$rows = DB::select(
|
||||
'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where user_id = ? order by sort_order, id',
|
||||
(string) $userId
|
||||
'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where user_id = ? order by sort_order, id limit ? offset ?',
|
||||
(string) $userId,
|
||||
(string) $limit,
|
||||
(string) $offset
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
@@ -249,60 +244,31 @@ class BookmarkRepository implements BookmarkRepositoryInterface
|
||||
}
|
||||
}
|
||||
|
||||
public function clearGroupId(int $groupId, int $userId): bool
|
||||
/** @return list<array<string, mixed>> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||
|
||||
interface BookmarkRepositoryInterface
|
||||
{
|
||||
@@ -1,38 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Bookmark;
|
||||
namespace MintyPHP\Module\Bookmarks\Service;
|
||||
|
||||
use MintyPHP\Repository\User\BookmarkGroupRepository;
|
||||
use MintyPHP\Repository\User\BookmarkNavigationRepository;
|
||||
use MintyPHP\Repository\User\BookmarkRepository;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepository;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepository;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepository;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
|
||||
|
||||
class BookmarkServicesFactory
|
||||
class BookmarkRepositoryFactory
|
||||
{
|
||||
private ?BookmarkService $bookmarkService = null;
|
||||
private ?BookmarkRepository $bookmarkRepository = null;
|
||||
private ?BookmarkGroupRepository $groupRepository = null;
|
||||
private ?BookmarkNavigationRepository $navigationRepository = null;
|
||||
|
||||
public function createBookmarkService(): BookmarkService
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Bookmark;
|
||||
namespace MintyPHP\Module\Bookmarks\Service;
|
||||
|
||||
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkRepositoryInterface;
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
|
||||
|
||||
class BookmarkService
|
||||
{
|
||||
@@ -94,7 +94,7 @@ class BookmarkService
|
||||
return ['ok' => 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<array{id: int, sort_order: int}> $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<array{kind: 'group'|'bookmark', id: int, sort_order: int}> $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<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Bookmarks\Service;
|
||||
|
||||
class BookmarkServicesFactory
|
||||
{
|
||||
private ?BookmarkService $bookmarkService = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookmarkRepositoryFactory $bookmarkRepositoryFactory
|
||||
) {
|
||||
}
|
||||
|
||||
public function createBookmarkService(): BookmarkService
|
||||
{
|
||||
return $this->bookmarkService ??= new BookmarkService(
|
||||
$this->bookmarkRepositoryFactory->createBookmarkRepository(),
|
||||
$this->bookmarkRepositoryFactory->createBookmarkGroupRepository(),
|
||||
$this->bookmarkRepositoryFactory->createBookmarkNavigationRepository()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
namespace MintyPHP\Module\Bookmarks\Support;
|
||||
|
||||
final class BookmarkUrlNormalizer
|
||||
{
|
||||
136
modules/bookmarks/module.php
Normal file
136
modules/bookmarks/module.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bookmarks module manifest.
|
||||
*
|
||||
* Owns bookmark routes, topbar/aside/dialog UI, runtime components,
|
||||
* and bookmark session/layout providers.
|
||||
*/
|
||||
return [
|
||||
'id' => '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,
|
||||
];
|
||||
@@ -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']);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
242
modules/bookmarks/templates/aside-bookmarks-panel.phtml
Normal file
242
modules/bookmarks/templates/aside-bookmarks-panel.phtml
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
|
||||
|
||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||
$bookmarkNav = is_array($layoutNav['bookmarks.nav'] ?? null) ? $layoutNav['bookmarks.nav'] : [];
|
||||
$bookmarkData = is_array($bookmarkNav['grouped'] ?? null) ? $bookmarkNav['grouped'] : ['groups' => [], 'ungrouped' => []];
|
||||
$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : [];
|
||||
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
|
||||
$currentPath = trim((string) ($bookmarkNav['current_path'] ?? ''));
|
||||
?>
|
||||
<div data-app-bookmark-panel data-app-component="bookmark-panel"
|
||||
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"
|
||||
data-bookmark-group-delete-url="<?php e(lurl('bookmarks/group-delete-data')); ?>"
|
||||
data-bookmark-delete-url="<?php e(lurl('bookmarks/delete-data')); ?>"
|
||||
data-bookmark-msg-group-delete-confirm="<?php e(t('Delete group and keep bookmarks?')); ?>"
|
||||
data-bookmark-msg-group-deleted="<?php e(t('Group deleted')); ?>"
|
||||
data-bookmark-msg-group-updated="<?php e(t('Group updated')); ?>"
|
||||
data-bookmark-msg-bookmark-delete-confirm="<?php e(t('Delete this bookmark?')); ?>"
|
||||
data-bookmark-msg-bookmark-deleted="<?php e(t('Bookmark deleted')); ?>"
|
||||
data-bookmark-msg-group-action-failed="<?php e(t('Group action failed')); ?>"
|
||||
data-bookmark-msg-bookmark-action-failed="<?php e(t('Bookmark action failed')); ?>"
|
||||
data-bookmark-msg-group-delete-failed="<?php e(t('Group delete failed')); ?>"
|
||||
data-bookmark-msg-bookmark-delete-failed="<?php e(t('Bookmark action failed')); ?>"
|
||||
data-bookmark-msg-reorder-saved="<?php e(t('Reorder saved')); ?>"
|
||||
data-bookmark-msg-reorder-failed="<?php e(t('Reorder failed')); ?>"
|
||||
data-bookmark-label-delete="<?php e(t('Delete')); ?>">
|
||||
<?php if (!$bookmarkGroups && !$bookmarkUngrouped): ?>
|
||||
<?php
|
||||
$emptyState = [
|
||||
'message' => t('No bookmarks yet'),
|
||||
'size' => 'compact',
|
||||
'align' => 'center',
|
||||
];
|
||||
require templatePath('partials/app-empty-state.phtml');
|
||||
?>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$bookmarkRootItems = [];
|
||||
foreach ($bookmarkUngrouped as $bookmark) {
|
||||
$bookmarkId = (int) ($bookmark['id'] ?? 0);
|
||||
if ($bookmarkId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$bookmarkRootItems[] = [
|
||||
'type' => '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);
|
||||
?>
|
||||
<ul data-bookmark-root-list>
|
||||
<?php foreach ($bookmarkRootItems as $rootIndex => $rootItem): ?>
|
||||
<?php
|
||||
$rootType = (string) ($rootItem['type'] ?? '');
|
||||
$rootFirst = $rootIndex === 0;
|
||||
$rootLast = $rootIndex === ($rootCount - 1);
|
||||
?>
|
||||
<?php if ($rootType === 'bookmark'): ?>
|
||||
<?php
|
||||
$bookmark = is_array($rootItem['bookmark'] ?? null) ? $rootItem['bookmark'] : [];
|
||||
$bookmarkId = (int) ($bookmark['id'] ?? 0);
|
||||
$bookmarkName = trim((string) ($bookmark['name'] ?? ''));
|
||||
$bookmarkUrl = trim((string) ($bookmark['url'] ?? ''));
|
||||
$canonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bookmarkUrl);
|
||||
$bookmarkActive = $canonicalUrl !== '' && $currentPath === $canonicalUrl;
|
||||
?>
|
||||
<li class="app-sidebar-bookmark-root-item app-sidebar-bookmark-item"
|
||||
data-bookmark-root-kind="bookmark"
|
||||
data-bookmark-root-id="<?php e($bookmarkId); ?>"
|
||||
data-bookmark-id="<?php e($bookmarkId); ?>"
|
||||
data-bookmark-name="<?php e($bookmarkName); ?>"
|
||||
data-bookmark-group-id="">
|
||||
<a href="<?php e(lurl($bookmarkUrl)); ?>"
|
||||
class="<?php e($bookmarkActive ? 'active' : ''); ?>"
|
||||
<?php echo $bookmarkActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($bookmarkName); ?>
|
||||
</a>
|
||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" data-bookmark-item-action-scope="root" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" data-bookmark-item-action-scope="root" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
||||
<?php e(t('Delete bookmark')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$group = is_array($rootItem['group'] ?? null) ? $rootItem['group'] : [];
|
||||
$groupId = (int) ($group['id'] ?? 0);
|
||||
$groupName = trim((string) ($group['name'] ?? ''));
|
||||
$groupIcon = trim((string) ($group['icon'] ?? 'bi-folder'));
|
||||
$groupBookmarks = is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : [];
|
||||
?>
|
||||
<li class="app-sidebar-group app-sidebar-bookmark-group app-sidebar-bookmark-root-item"
|
||||
data-bookmark-root-kind="group"
|
||||
data-bookmark-root-id="<?php e($groupId); ?>"
|
||||
data-bookmark-group-id="<?php e($groupId); ?>"
|
||||
data-bookmark-group-name="<?php e($groupName); ?>"
|
||||
data-bookmark-group-icon="<?php e($groupIcon); ?>">
|
||||
<div class="app-sidebar-bookmark-group-shell">
|
||||
<div class="app-sidebar-bookmark-group-header">
|
||||
<small class="app-sidebar-bookmark-group-label">
|
||||
<i class="bi <?php e($groupIcon); ?>" aria-hidden="true"></i>
|
||||
<span><?php e($groupName); ?></span>
|
||||
</small>
|
||||
<details class="app-sidebar-bookmark-action-menu app-sidebar-bookmark-group-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Group actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="move-up" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move group up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="move-down" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move group down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-group-action="delete">
|
||||
<?php e(t('Delete group')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<?php if ($groupBookmarks): ?>
|
||||
<ul data-bookmark-group-bookmark-list>
|
||||
<?php $groupBookmarkCount = count($groupBookmarks); ?>
|
||||
<?php foreach ($groupBookmarks as $bookmarkIndex => $bookmark): ?>
|
||||
<?php
|
||||
$bookmarkId = (int) ($bookmark['id'] ?? 0);
|
||||
$bookmarkName = trim((string) ($bookmark['name'] ?? ''));
|
||||
$bookmarkUrl = trim((string) ($bookmark['url'] ?? ''));
|
||||
$canonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bookmarkUrl);
|
||||
$bookmarkActive = $canonicalUrl !== '' && $currentPath === $canonicalUrl;
|
||||
$bookmarkFirst = $bookmarkIndex === 0;
|
||||
$bookmarkLast = $bookmarkIndex === ($groupBookmarkCount - 1);
|
||||
?>
|
||||
<li class="app-sidebar-bookmark-item"
|
||||
data-bookmark-id="<?php e($bookmarkId); ?>"
|
||||
data-bookmark-name="<?php e($bookmarkName); ?>"
|
||||
data-bookmark-group-id="<?php e($groupId); ?>">
|
||||
<a href="<?php e(lurl($bookmarkUrl)); ?>"
|
||||
class="<?php e($bookmarkActive ? 'active' : ''); ?>"
|
||||
<?php echo $bookmarkActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($bookmarkName); ?>
|
||||
</a>
|
||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" <?php if ($bookmarkFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" <?php if ($bookmarkLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
||||
<?php e(t('Delete bookmark')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
|
||||
$bmSessionData = is_array(app(SessionStoreInterface::class)->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'] : [];
|
||||
?>
|
||||
<dialog
|
||||
data-app-bookmark-dialog
|
||||
@@ -37,18 +37,19 @@ $bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['
|
||||
<label for="bookmark-group"><?php e(t('Assign to group')); ?></label>
|
||||
<select id="bookmark-group" name="group_id" data-bookmark-group-select>
|
||||
<option value=""><?php e(t('No group')); ?></option>
|
||||
<?php foreach ($bmSessionGroups as $grp):
|
||||
$grpId = (int) ($grp['id'] ?? 0);
|
||||
$grpName = trim((string) ($grp['name'] ?? ''));
|
||||
if ($grpId <= 0 || $grpName === '') { continue; }
|
||||
<?php foreach ($bookmarkGroups as $group):
|
||||
$groupId = (int) ($group['id'] ?? 0);
|
||||
$groupName = trim((string) ($group['name'] ?? ''));
|
||||
if ($groupId <= 0 || $groupName === '') { continue; }
|
||||
?>
|
||||
<option value="<?php e($grpId); ?>"><?php e($grpName); ?></option>
|
||||
<option value="<?php e($groupId); ?>"><?php e($groupName); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="outline secondary app-bookmark-new-group-btn" data-bookmark-new-group>
|
||||
<i class="bi bi-plus"></i> <?php e(t('New group')); ?>
|
||||
</button>
|
||||
<footer>
|
||||
<button type="button" class="secondary outline" data-bookmark-dialog-close><?php e(t('Cancel')); ?></button>
|
||||
<button type="submit" class="app-action-success" data-bookmark-submit-label><?php e(t('Save')); ?></button>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -89,6 +90,7 @@ $bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['
|
||||
</div>
|
||||
<input type="hidden" name="icon" value="bi-folder">
|
||||
<footer>
|
||||
<button type="button" class="secondary outline" data-bookmark-group-dialog-close><?php e(t('Cancel')); ?></button>
|
||||
<button type="submit" class="app-action-success" data-bookmark-group-submit-label><?php e(t('Save')); ?></button>
|
||||
</footer>
|
||||
</form>
|
||||
46
modules/bookmarks/templates/topbar-bookmark-button.phtml
Normal file
46
modules/bookmarks/templates/topbar-bookmark-button.phtml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
|
||||
|
||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||
$bookmarkNav = is_array($layoutNav['bookmarks.nav'] ?? null) ? $layoutNav['bookmarks.nav'] : [];
|
||||
$bookmarkData = is_array($bookmarkNav['grouped'] ?? null) ? $bookmarkNav['grouped'] : ['groups' => [], '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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<li>
|
||||
<button type="button" data-bookmark-open-dialog
|
||||
aria-label="<?php e($existing ? t('Edit bookmark') : t('Bookmarks')); ?>"
|
||||
data-tooltip="<?php e($existing ? t('Edit bookmark') : t('Bookmarks')); ?>" data-tooltip-pos="bottom"
|
||||
<?php if ($existing): ?>
|
||||
data-bookmark-existing-id="<?php e((int) ($existing['id'] ?? 0)); ?>"
|
||||
data-bookmark-existing-name="<?php e(trim((string) ($existing['name'] ?? ''))); ?>"
|
||||
data-bookmark-existing-group="<?php e($existing['group_id'] ?? ''); ?>"
|
||||
<?php endif; ?>>
|
||||
<i class="bi <?php e($existing ? 'bi-bookmark-check-fill' : 'bi-bookmark-plus'); ?>"></i>
|
||||
</button>
|
||||
</li>
|
||||
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Bookmark;
|
||||
namespace MintyPHP\Tests\Module\Bookmarks\Service;
|
||||
|
||||
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkRepositoryInterface;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
|
||||
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Bookmarks\Support;
|
||||
|
||||
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookmarkUrlNormalizerTest extends TestCase
|
||||
{
|
||||
public function testCanonicalizeRelativeNormalizesPathAndQueryOrder(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRelative('/admin//users/?b=2&a=1');
|
||||
|
||||
$this->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,<script>alert(1)</script>'));
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
185
modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css
Normal file
185
modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
227
tests/Architecture/FrontendComponentRuntimeContractTest.php
Normal file
227
tests/Architecture/FrontendComponentRuntimeContractTest.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FrontendComponentRuntimeContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
public function testEntrypointsUseRuntimeRegistrationWithoutSideeffectComponentImports(): void
|
||||
{
|
||||
$defaultEntry = $this->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);
|
||||
}
|
||||
}
|
||||
84
tests/Architecture/NoBookmarksHardcodingTest.php
Normal file
84
tests/Architecture/NoBookmarksHardcodingTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NoBookmarksHardcodingTest extends TestCase
|
||||
{
|
||||
public function testCoreTemplatesHaveNoBookmarkSpecificMarkup(): void
|
||||
{
|
||||
$files = [
|
||||
dirname(__DIR__, 2) . '/templates/default.phtml',
|
||||
dirname(__DIR__, 2) . '/templates/partials/app-topbar.phtml',
|
||||
dirname(__DIR__, 2) . '/templates/partials/app-main-aside.phtml',
|
||||
dirname(__DIR__, 2) . '/templates/partials/app-main-aside-icon-bar.phtml',
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$content = file_get_contents($file);
|
||||
self::assertIsString($content);
|
||||
self::assertStringNotContainsString('aside-tab-bookmarks', $content, $file);
|
||||
self::assertStringNotContainsString('aside-panel-bookmarks', $content, $file);
|
||||
self::assertStringNotContainsString('data-app-bookmark-dialog', $content, $file);
|
||||
self::assertStringNotContainsString('bookmarks.nav', $content, $file);
|
||||
self::assertStringNotContainsString("components.bookmark", $content, $file);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCoreBookmarkEntrypointsAreRemoved(): void
|
||||
{
|
||||
$root = dirname(__DIR__, 2);
|
||||
|
||||
self::assertFileDoesNotExist($root . '/templates/partials/app-bookmark-dialog.phtml');
|
||||
self::assertFileDoesNotExist($root . '/web/js/components/app-bookmark-save.js');
|
||||
self::assertFileDoesNotExist($root . '/web/js/components/app-bookmark-panel.js');
|
||||
self::assertFileDoesNotExist($root . '/web/css/components/app-bookmark-form.css');
|
||||
self::assertDirectoryDoesNotExist($root . '/pages/bookmarks');
|
||||
}
|
||||
|
||||
public function testCoreNoLongerContainsBookmarkServiceDirectory(): void
|
||||
{
|
||||
$root = dirname(__DIR__, 2);
|
||||
self::assertDirectoryDoesNotExist(
|
||||
$root . '/lib/Service/Bookmark',
|
||||
'Bookmark service classes should live in modules/bookmarks/lib, not in Core lib/Service'
|
||||
);
|
||||
}
|
||||
|
||||
public function testCoreNoLongerContainsBookmarkRepositoryFiles(): void
|
||||
{
|
||||
$root = dirname(__DIR__, 2);
|
||||
$repoDir = $root . '/lib/Repository/User';
|
||||
if (!is_dir($repoDir)) {
|
||||
self::addToAssertionCount(1);
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = scandir($repoDir) ?: [];
|
||||
$bookmarkFiles = array_filter($entries, static fn (string $f): bool => 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);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Support;
|
||||
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookmarkUrlNormalizerTest extends TestCase
|
||||
{
|
||||
public function testCanonicalizeRelativeNormalizesPathAndQueryOrder(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRelative('/admin//users/?b=2&a=1');
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
45
tests/Unit/Module/BookmarksAuthorizationPolicyTest.php
Normal file
45
tests/Unit/Module/BookmarksAuthorizationPolicyTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Unit\Module;
|
||||
|
||||
use MintyPHP\Module\Bookmarks\BookmarksAuthorizationPolicy;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BookmarksAuthorizationPolicyTest extends TestCase
|
||||
{
|
||||
public function testImplementsAuthorizationPolicyContract(): void
|
||||
{
|
||||
$policy = new BookmarksAuthorizationPolicy();
|
||||
self::assertInstanceOf(AuthorizationPolicyInterface::class, $policy);
|
||||
}
|
||||
|
||||
public function testSupportsBookmarksAbilityOnly(): void
|
||||
{
|
||||
$policy = new BookmarksAuthorizationPolicy();
|
||||
|
||||
self::assertTrue($policy->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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
1
web/modules/bookmarks
Symbolic link
1
web/modules/bookmarks
Symbolic link
@@ -0,0 +1 @@
|
||||
/var/www/modules/bookmarks/web
|
||||
Reference in New Issue
Block a user