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
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Repository\User;
|
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||||
|
|
||||||
use MintyPHP\DB;
|
use MintyPHP\DB;
|
||||||
use MintyPHP\Repository\Support\RepoQuery;
|
use MintyPHP\Repository\Support\RepoQuery;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Repository\User;
|
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||||
|
|
||||||
interface BookmarkGroupRepositoryInterface
|
interface BookmarkGroupRepositoryInterface
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Repository\User;
|
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||||
|
|
||||||
use MintyPHP\DB;
|
use MintyPHP\DB;
|
||||||
|
|
||||||
@@ -36,9 +36,9 @@ class BookmarkNavigationRepository implements BookmarkNavigationRepositoryInterf
|
|||||||
$bookmarkIds = [];
|
$bookmarkIds = [];
|
||||||
|
|
||||||
foreach ($rootOrderPairs as $pair) {
|
foreach ($rootOrderPairs as $pair) {
|
||||||
$kind = trim((string) ($pair['kind'] ?? ''));
|
$kind = trim((string) $pair['kind']);
|
||||||
$id = (int) ($pair['id'] ?? 0);
|
$id = (int) $pair['id'];
|
||||||
$sortOrder = (int) ($pair['sort_order'] ?? 0);
|
$sortOrder = (int) $pair['sort_order'];
|
||||||
if ($id <= 0 || $sortOrder <= 0 || ($kind !== 'group' && $kind !== 'bookmark')) {
|
if ($id <= 0 || $sortOrder <= 0 || ($kind !== 'group' && $kind !== 'bookmark')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Repository\User;
|
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||||
|
|
||||||
interface BookmarkNavigationRepositoryInterface
|
interface BookmarkNavigationRepositoryInterface
|
||||||
{
|
{
|
||||||
@@ -1,34 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Repository\User;
|
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||||
|
|
||||||
use MintyPHP\DB;
|
use MintyPHP\DB;
|
||||||
|
use MintyPHP\Repository\Support\RepoQuery;
|
||||||
|
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||||
|
|
||||||
class BookmarkRepository implements BookmarkRepositoryInterface
|
class BookmarkRepository implements BookmarkRepositoryInterface
|
||||||
{
|
{
|
||||||
private function unwrapList(mixed $rows): array
|
private function unwrapList(mixed $rows): array
|
||||||
{
|
{
|
||||||
if (!is_array($rows)) {
|
return RepositoryArrayHelper::unwrapList($rows, 'user_bookmarks');
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$list = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$data = $row['user_bookmarks'] ?? $row;
|
|
||||||
if (is_array($data)) {
|
|
||||||
$list[] = $data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listByUser(int $userId): array
|
public function listByUser(int $userId, array $options = []): array
|
||||||
{
|
{
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options, 100, 1, 100);
|
||||||
$rows = DB::select(
|
$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',
|
'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) $userId,
|
||||||
|
(string) $limit,
|
||||||
|
(string) $offset
|
||||||
);
|
);
|
||||||
return $this->unwrapList($rows);
|
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) {
|
if ($userId <= 0 || $groupId <= 0) {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$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(
|
$rows = DB::select(
|
||||||
'select id from user_bookmarks where user_id = ? and group_id = ? order by sort_order, id',
|
'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) $userId,
|
||||||
(string) $groupId
|
(string) $groupId
|
||||||
);
|
);
|
||||||
$bookmarks = $this->unwrapList($rows);
|
return $this->unwrapList($rows);
|
||||||
|
}
|
||||||
foreach ($bookmarks as $bookmark) {
|
|
||||||
$bookmarkId = (int) ($bookmark['id'] ?? 0);
|
public function unsetGroupId(int $bookmarkId, int $userId, int $sortOrder): bool
|
||||||
if ($bookmarkId <= 0) {
|
{
|
||||||
continue;
|
if ($bookmarkId <= 0 || $userId <= 0 || $sortOrder <= 0) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
$nextSort++;
|
|
||||||
$updated = DB::update(
|
$updated = DB::update(
|
||||||
'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?',
|
'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?',
|
||||||
(string) $nextSort,
|
(string) $sortOrder,
|
||||||
(string) $bookmarkId,
|
(string) $bookmarkId,
|
||||||
(string) $userId
|
(string) $userId
|
||||||
);
|
);
|
||||||
if ($updated === false) {
|
return $updated !== false;
|
||||||
$db->rollback();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->commit();
|
|
||||||
return true;
|
|
||||||
} catch (\Throwable) {
|
|
||||||
try {
|
|
||||||
$db->rollback();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Repository\User;
|
namespace MintyPHP\Module\Bookmarks\Repository;
|
||||||
|
|
||||||
interface BookmarkRepositoryInterface
|
interface BookmarkRepositoryInterface
|
||||||
{
|
{
|
||||||
@@ -1,38 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Service\Bookmark;
|
namespace MintyPHP\Module\Bookmarks\Service;
|
||||||
|
|
||||||
use MintyPHP\Repository\User\BookmarkGroupRepository;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepository;
|
||||||
use MintyPHP\Repository\User\BookmarkNavigationRepository;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
|
||||||
use MintyPHP\Repository\User\BookmarkRepository;
|
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 ?BookmarkRepository $bookmarkRepository = null;
|
||||||
private ?BookmarkGroupRepository $groupRepository = null;
|
private ?BookmarkGroupRepository $groupRepository = null;
|
||||||
private ?BookmarkNavigationRepository $navigationRepository = null;
|
private ?BookmarkNavigationRepository $navigationRepository = null;
|
||||||
|
|
||||||
public function createBookmarkService(): BookmarkService
|
public function createBookmarkRepository(): BookmarkRepositoryInterface
|
||||||
{
|
|
||||||
return $this->bookmarkService ??= new BookmarkService(
|
|
||||||
$this->createBookmarkRepository(),
|
|
||||||
$this->createBookmarkGroupRepository(),
|
|
||||||
$this->createBookmarkNavigationRepository()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createBookmarkRepository(): BookmarkRepository
|
|
||||||
{
|
{
|
||||||
return $this->bookmarkRepository ??= new BookmarkRepository();
|
return $this->bookmarkRepository ??= new BookmarkRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createBookmarkGroupRepository(): BookmarkGroupRepository
|
public function createBookmarkGroupRepository(): BookmarkGroupRepositoryInterface
|
||||||
{
|
{
|
||||||
return $this->groupRepository ??= new BookmarkGroupRepository();
|
return $this->groupRepository ??= new BookmarkGroupRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createBookmarkNavigationRepository(): BookmarkNavigationRepository
|
public function createBookmarkNavigationRepository(): BookmarkNavigationRepositoryInterface
|
||||||
{
|
{
|
||||||
return $this->navigationRepository ??= new BookmarkNavigationRepository();
|
return $this->navigationRepository ??= new BookmarkNavigationRepository();
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Service\Bookmark;
|
namespace MintyPHP\Module\Bookmarks\Service;
|
||||||
|
|
||||||
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
|
||||||
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
|
||||||
use MintyPHP\Repository\User\BookmarkRepositoryInterface;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
|
||||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
|
||||||
|
|
||||||
class BookmarkService
|
class BookmarkService
|
||||||
{
|
{
|
||||||
@@ -94,7 +94,7 @@ class BookmarkService
|
|||||||
return ['ok' => false, 'error' => 'url_required'];
|
return ['ok' => false, 'error' => 'url_required'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$existing = $this->findExistingByCanonicalUrl($userId, $url);
|
$existing = $this->bookmarkRepository->findByUserAndUrl($userId, $url);
|
||||||
|
|
||||||
if ($groupId !== null && $groupId > 0) {
|
if ($groupId !== null && $groupId > 0) {
|
||||||
$group = $this->groupRepository->findById($groupId, $userId);
|
$group = $this->groupRepository->findById($groupId, $userId);
|
||||||
@@ -315,9 +315,20 @@ class BookmarkService
|
|||||||
|
|
||||||
public function deleteGroup(int $userId, int $groupId): bool
|
public function deleteGroup(int $userId, int $groupId): bool
|
||||||
{
|
{
|
||||||
if (!$this->bookmarkRepository->clearGroupId($groupId, $userId)) {
|
$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;
|
return false;
|
||||||
}
|
}
|
||||||
|
$nextSort++;
|
||||||
|
}
|
||||||
|
|
||||||
return $this->groupRepository->delete($groupId, $userId);
|
return $this->groupRepository->delete($groupId, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +344,42 @@ class BookmarkService
|
|||||||
return $this->navigationRepository->updateRootSortOrders($userId, $rootOrderPairs);
|
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
|
private function sanitizeUrl(string $url): string
|
||||||
{
|
{
|
||||||
$url = BookmarkUrlNormalizer::canonicalizeRelative($url);
|
$url = BookmarkUrlNormalizer::canonicalizeRelative($url);
|
||||||
@@ -364,22 +411,4 @@ class BookmarkService
|
|||||||
return $value;
|
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
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Support;
|
namespace MintyPHP\Module\Bookmarks\Support;
|
||||||
|
|
||||||
final class BookmarkUrlNormalizer
|
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\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\Guard;
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
@@ -22,13 +22,27 @@ if (!Session::checkCsrfToken()) {
|
|||||||
|
|
||||||
$session = app(SessionStoreInterface::class)->all();
|
$session = app(SessionStoreInterface::class)->all();
|
||||||
$userId = (int) ($session['user']['id'] ?? 0);
|
$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');
|
$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);
|
$service = app(BookmarkService::class);
|
||||||
$deleted = $service->deleteBookmark($userId, $bookmarkId);
|
$deleted = $service->deleteBookmark($userId, $bookmarkId);
|
||||||
|
|
||||||
if ($deleted) {
|
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\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\Guard;
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
@@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) {
|
|||||||
|
|
||||||
$session = app(SessionStoreInterface::class)->all();
|
$session = app(SessionStoreInterface::class)->all();
|
||||||
$userId = (int) ($session['user']['id'] ?? 0);
|
$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');
|
$groupId = (int) requestInput()->body('id');
|
||||||
if ($groupId <= 0) {
|
if ($groupId <= 0) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -33,7 +39,7 @@ $service = app(BookmarkService::class);
|
|||||||
$deleted = $service->deleteGroup($userId, $groupId);
|
$deleted = $service->deleteGroup($userId, $groupId);
|
||||||
|
|
||||||
if ($deleted) {
|
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]);
|
Router::json(['ok' => true]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\Guard;
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
@@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) {
|
|||||||
|
|
||||||
$session = app(SessionStoreInterface::class)->all();
|
$session = app(SessionStoreInterface::class)->all();
|
||||||
$userId = (int) ($session['user']['id'] ?? 0);
|
$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'));
|
$name = trim((string) requestInput()->body('name'));
|
||||||
$icon = trim((string) requestInput()->body('icon'));
|
$icon = trim((string) requestInput()->body('icon'));
|
||||||
$groupId = requestInput()->body('id');
|
$groupId = requestInput()->body('id');
|
||||||
@@ -31,7 +37,9 @@ $service = app(BookmarkService::class);
|
|||||||
$result = $service->saveGroup($userId, $name, $icon, $groupId);
|
$result = $service->saveGroup($userId, $name, $icon, $groupId);
|
||||||
|
|
||||||
if ($result['ok']) {
|
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);
|
Router::json($result);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\Guard;
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
@@ -22,18 +22,17 @@ if (!Session::checkCsrfToken()) {
|
|||||||
|
|
||||||
$session = app(SessionStoreInterface::class)->all();
|
$session = app(SessionStoreInterface::class)->all();
|
||||||
$userId = (int) ($session['user']['id'] ?? 0);
|
$userId = (int) ($session['user']['id'] ?? 0);
|
||||||
$type = trim((string) requestInput()->body('type'));
|
if ($userId <= 0) {
|
||||||
$allowedTypes = ['groups', 'bookmarks', 'root'];
|
http_response_code(401);
|
||||||
if (!in_array($type, $allowedTypes, true)) {
|
Router::json(['ok' => false, 'error' => 'unauthorized']);
|
||||||
http_response_code(400);
|
|
||||||
Router::json(['ok' => false, 'error' => 'invalid_type']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$type = trim((string) requestInput()->body('type'));
|
||||||
$rawItems = requestInput()->body('items');
|
$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);
|
http_response_code(400);
|
||||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||||
return;
|
return;
|
||||||
@@ -48,15 +47,18 @@ if ($type === 'root') {
|
|||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
if (!is_array($item)) {
|
if (!is_array($item)) {
|
||||||
continue;
|
http_response_code(400);
|
||||||
|
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
$kind = trim((string) ($item['kind'] ?? ''));
|
$kind = trim((string) ($item['kind'] ?? ''));
|
||||||
$id = (int) ($item['id'] ?? 0);
|
$id = (int) ($item['id'] ?? 0);
|
||||||
$sortOrder = (int) ($item['sort_order'] ?? 0);
|
$sortOrder = (int) ($item['sort_order'] ?? 0);
|
||||||
if (($kind !== 'group' && $kind !== 'bookmark') || $id <= 0 || $sortOrder <= 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;
|
$dedupeKey = $kind . ':' . $id;
|
||||||
if (isset($seenRootIds[$dedupeKey]) || isset($seenSortOrders[$sortOrder])) {
|
if (isset($seenRootIds[$dedupeKey]) || isset($seenSortOrders[$sortOrder])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -65,31 +67,27 @@ if ($type === 'root') {
|
|||||||
}
|
}
|
||||||
$seenRootIds[$dedupeKey] = true;
|
$seenRootIds[$dedupeKey] = true;
|
||||||
$seenSortOrders[$sortOrder] = true;
|
$seenSortOrders[$sortOrder] = true;
|
||||||
$rootPairs[] = [
|
$rootPairs[] = ['kind' => $kind, 'id' => $id, 'sort_order' => $sortOrder];
|
||||||
'kind' => $kind,
|
|
||||||
'id' => $id,
|
|
||||||
'sort_order' => $sortOrder,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$pairCount = count($rootPairs);
|
$result = $service->reorderRootFromItems($userId, $rootPairs);
|
||||||
if ($pairCount === 0 || $pairCount !== count($items)) {
|
} else {
|
||||||
|
$pairs = [];
|
||||||
|
$seenIds = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ok = $service->reorderRoot($userId, $rootPairs);
|
|
||||||
} else {
|
|
||||||
$pairs = [];
|
|
||||||
$seenIds = [];
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if (!is_array($item)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$id = (int) ($item['id'] ?? 0);
|
$id = (int) ($item['id'] ?? 0);
|
||||||
$sortOrder = (int) ($item['sort_order'] ?? 0);
|
$sortOrder = (int) ($item['sort_order'] ?? 0);
|
||||||
if ($id > 0 && $sortOrder > 0) {
|
if ($id <= 0 || $sortOrder <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isset($seenIds[$id])) {
|
if (isset($seenIds[$id])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||||
@@ -98,25 +96,15 @@ if ($type === 'root') {
|
|||||||
$seenIds[$id] = true;
|
$seenIds[$id] = true;
|
||||||
$pairs[] = ['id' => $id, 'sort_order' => $sortOrder];
|
$pairs[] = ['id' => $id, 'sort_order' => $sortOrder];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$pairCount = count($pairs);
|
$result = $service->reorderFromItems($userId, $type, $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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ok) {
|
if ($result['ok']) {
|
||||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
|
||||||
Router::json(['ok' => true]);
|
Router::json(['ok' => true]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
Router::json(['ok' => false, 'error' => 'reorder_failed']);
|
Router::json($result);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\Guard;
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
@@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) {
|
|||||||
|
|
||||||
$session = app(SessionStoreInterface::class)->all();
|
$session = app(SessionStoreInterface::class)->all();
|
||||||
$userId = (int) ($session['user']['id'] ?? 0);
|
$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'));
|
$name = trim((string) requestInput()->body('name'));
|
||||||
$url = trim((string) requestInput()->body('url'));
|
$url = trim((string) requestInput()->body('url'));
|
||||||
$groupId = requestInput()->body('group_id');
|
$groupId = requestInput()->body('group_id');
|
||||||
@@ -31,7 +37,9 @@ $service = app(BookmarkService::class);
|
|||||||
$result = $service->saveBookmark($userId, $name, $url, $groupId);
|
$result = $service->saveBookmark($userId, $name, $url, $groupId);
|
||||||
|
|
||||||
if ($result['ok']) {
|
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);
|
Router::json($result);
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Router;
|
use MintyPHP\Router;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
use MintyPHP\Session;
|
use MintyPHP\Session;
|
||||||
use MintyPHP\Support\Guard;
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ $service = app(BookmarkService::class);
|
|||||||
$result = $service->updateBookmark($userId, $bookmarkId, $data);
|
$result = $service->updateBookmark($userId, $bookmarkId, $data);
|
||||||
|
|
||||||
if ($result['ok']) {
|
if ($result['ok']) {
|
||||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
|
||||||
} else {
|
} else {
|
||||||
http_response_code(400);
|
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
|
<?php
|
||||||
|
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
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'] : [];
|
|
||||||
|
|
||||||
|
$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
|
<dialog
|
||||||
data-app-bookmark-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>
|
<label for="bookmark-group"><?php e(t('Assign to group')); ?></label>
|
||||||
<select id="bookmark-group" name="group_id" data-bookmark-group-select>
|
<select id="bookmark-group" name="group_id" data-bookmark-group-select>
|
||||||
<option value=""><?php e(t('No group')); ?></option>
|
<option value=""><?php e(t('No group')); ?></option>
|
||||||
<?php foreach ($bmSessionGroups as $grp):
|
<?php foreach ($bookmarkGroups as $group):
|
||||||
$grpId = (int) ($grp['id'] ?? 0);
|
$groupId = (int) ($group['id'] ?? 0);
|
||||||
$grpName = trim((string) ($grp['name'] ?? ''));
|
$groupName = trim((string) ($group['name'] ?? ''));
|
||||||
if ($grpId <= 0 || $grpName === '') { continue; }
|
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; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="outline secondary app-bookmark-new-group-btn" data-bookmark-new-group>
|
<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')); ?>
|
<i class="bi bi-plus"></i> <?php e(t('New group')); ?>
|
||||||
</button>
|
</button>
|
||||||
<footer>
|
<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>
|
<button type="submit" class="app-action-success" data-bookmark-submit-label><?php e(t('Save')); ?></button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
@@ -89,6 +90,7 @@ $bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="icon" value="bi-folder">
|
<input type="hidden" name="icon" value="bi-folder">
|
||||||
<footer>
|
<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>
|
<button type="submit" class="app-action-success" data-bookmark-group-submit-label><?php e(t('Save')); ?></button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</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
|
<?php
|
||||||
|
|
||||||
namespace MintyPHP\Tests\Service\Bookmark;
|
namespace MintyPHP\Tests\Module\Bookmarks\Service;
|
||||||
|
|
||||||
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
|
||||||
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
|
||||||
use MintyPHP\Repository\User\BookmarkRepositoryInterface;
|
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
|
||||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
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-dialog] form footer,
|
||||||
dialog[data-app-bookmark-group-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);
|
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 {
|
.app-bookmark-delete-btn {
|
||||||
color: var(--app-del-color, #c62828);
|
color: var(--app-del-color, #c62828);
|
||||||
border-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)
|
* - Reorder bookmarks inside groups (up/down)
|
||||||
* - Edit/Delete groups and bookmarks with confirm dialog
|
* - Edit/Delete groups and bookmarks with confirm dialog
|
||||||
*/
|
*/
|
||||||
import { showAsyncFlash } from './app-async-flash.js';
|
import { showAsyncFlash } from '../../../../js/components/app-async-flash.js';
|
||||||
import { warnOnce } from '../core/app-dom.js';
|
import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js';
|
||||||
import { confirmDialog } from '../core/app-confirm-dialog.js';
|
import { confirmDialog } from '../../../../js/core/app-confirm-dialog.js';
|
||||||
|
|
||||||
export function init() {
|
export function initBookmarkPanel(root = document, options = {}) {
|
||||||
const panel = document.querySelector('[data-aside-panel="bookmarks"]');
|
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)) {
|
if (!(panel instanceof HTMLElement)) {
|
||||||
return;
|
return { destroy: () => {} };
|
||||||
|
}
|
||||||
|
if (panel.dataset.bookmarkPanelBound === '1' && panel._bookmarkPanelApi) {
|
||||||
|
return panel._bookmarkPanelApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim();
|
const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim();
|
||||||
@@ -19,16 +24,19 @@ export function init() {
|
|||||||
const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim();
|
const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim();
|
||||||
if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) {
|
if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) {
|
||||||
warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' });
|
warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' });
|
||||||
return;
|
return { destroy: () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.documentElement;
|
const documentRoot = document.documentElement;
|
||||||
const csrfKey = String(root.dataset.csrfKey || '').trim();
|
const csrfKey = String(documentRoot.dataset.csrfKey || '').trim();
|
||||||
const csrfToken = String(root.dataset.csrfToken || '').trim();
|
const csrfToken = String(documentRoot.dataset.csrfToken || '').trim();
|
||||||
if (!csrfKey || !csrfToken) {
|
if (!csrfKey || !csrfToken) {
|
||||||
warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark panel', { module: 'bookmark-panel' });
|
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 messageDeleteConfirm = panel.dataset.bookmarkMsgGroupDeleteConfirm || 'Delete group and keep bookmarks?';
|
||||||
const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted';
|
const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted';
|
||||||
@@ -93,7 +101,8 @@ export function init() {
|
|||||||
if (String(successMessage).trim() !== '') {
|
if (String(successMessage).trim() !== '') {
|
||||||
showAsyncFlash('success', successMessage, 2500);
|
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) => {
|
const directChildren = (container, className) => {
|
||||||
@@ -494,7 +503,7 @@ export function init() {
|
|||||||
void reorderBookmark(itemActionButton);
|
void reorderBookmark(itemActionButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}, { signal });
|
||||||
|
|
||||||
document.addEventListener('click', (event) => {
|
document.addEventListener('click', (event) => {
|
||||||
if (!(event.target instanceof Node)) {
|
if (!(event.target instanceof Node)) {
|
||||||
@@ -504,5 +513,17 @@ export function init() {
|
|||||||
if (!clickedInsideMenu) {
|
if (!clickedInsideMenu) {
|
||||||
closeActionMenus();
|
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).
|
* Bookmark dialog (create/update) + group dialog (create/update).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { showAsyncFlash } from './app-async-flash.js';
|
import { showAsyncFlash } from '../../../../js/components/app-async-flash.js';
|
||||||
import { warnOnce } from '../core/app-dom.js';
|
import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js';
|
||||||
import { getAppBase } from '../pages/app-list-utils.js';
|
import { getAppBase } from '../../../../js/pages/app-list-utils.js';
|
||||||
|
|
||||||
export function initBookmarkSave(root = document) {
|
export function initBookmarkSave(root = document, options = {}) {
|
||||||
const host = root && typeof root.querySelector === 'function' ? root : document;
|
const host = resolveHost(root);
|
||||||
const openBtn = host.querySelector('[data-bookmark-open-dialog]') || document.querySelector('[data-bookmark-open-dialog]');
|
const openButtonSelector = String(options.openButtonSelector || '[data-bookmark-open-dialog]').trim() || '[data-bookmark-open-dialog]';
|
||||||
const bookmarkDialog = host.matches?.('[data-app-bookmark-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
|
||||||
: (host.querySelector('[data-app-bookmark-dialog]') || document.querySelector('[data-app-bookmark-dialog]'));
|
: host.querySelector(bookmarkDialogSelector);
|
||||||
if (!(bookmarkDialog instanceof HTMLDialogElement) || !(openBtn instanceof HTMLButtonElement)) {
|
if (!(bookmarkDialog instanceof HTMLDialogElement)) {
|
||||||
return { destroy: () => {} };
|
return { destroy: () => {} };
|
||||||
}
|
}
|
||||||
if (bookmarkDialog.dataset.bookmarkSaveBound === '1' && bookmarkDialog._bookmarkSaveApi) {
|
if (bookmarkDialog.dataset.bookmarkSaveBound === '1' && bookmarkDialog._bookmarkSaveApi) {
|
||||||
@@ -25,7 +28,7 @@ export function initBookmarkSave(root = document) {
|
|||||||
return { destroy: () => {} };
|
return { destroy: () => {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupDialog = document.querySelector('[data-app-bookmark-group-dialog]');
|
const groupDialog = host.querySelector(groupDialogSelector);
|
||||||
const groupForm = groupDialog instanceof HTMLDialogElement
|
const groupForm = groupDialog instanceof HTMLDialogElement
|
||||||
? groupDialog.querySelector('[data-bookmark-group-save-form]')
|
? groupDialog.querySelector('[data-bookmark-group-save-form]')
|
||||||
: null;
|
: null;
|
||||||
@@ -148,6 +151,17 @@ export function initBookmarkSave(root = document) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const configureBookmarkModeFromTopbar = () => {
|
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');
|
const isEditMode = openBtn.hasAttribute('data-bookmark-existing-id');
|
||||||
applyBookmarkModeUi(isEditMode);
|
applyBookmarkModeUi(isEditMode);
|
||||||
|
|
||||||
@@ -220,12 +234,14 @@ export function initBookmarkSave(root = document) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Bookmark dialog open
|
// Bookmark dialog open
|
||||||
|
if (openBtn instanceof HTMLButtonElement) {
|
||||||
openBtn.addEventListener('click', () => {
|
openBtn.addEventListener('click', () => {
|
||||||
configureBookmarkModeFromTopbar();
|
configureBookmarkModeFromTopbar();
|
||||||
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
|
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
|
||||||
nameInput.select();
|
nameInput.select();
|
||||||
}
|
}
|
||||||
}, { signal });
|
}, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('app:bookmark-edit', (event) => {
|
document.addEventListener('app:bookmark-edit', (event) => {
|
||||||
const detail = event instanceof CustomEvent ? event.detail : null;
|
const detail = event instanceof CustomEvent ? event.detail : null;
|
||||||
@@ -607,8 +623,7 @@ export function initBookmarkSave(root = document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentRelativeUrl() {
|
function currentRelativeUrl() {
|
||||||
const baseEl = document.querySelector('base');
|
const basePath = new URL(getAppBase()).pathname;
|
||||||
const basePath = baseEl ? new URL(baseEl.href).pathname : '/';
|
|
||||||
const full = window.location.pathname + window.location.search;
|
const full = window.location.pathname + window.location.search;
|
||||||
if (full.startsWith(basePath)) {
|
if (full.startsWith(basePath)) {
|
||||||
return full.slice(basePath.length);
|
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